mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Merge pull request #1098 from CodeBormen/fix/ghost-streams-stuck-initializing
fix: stuck channel states and ghost clients in TS proxy
This commit is contained in:
commit
cd0eba9654
8 changed files with 779 additions and 43 deletions
385
apps/channels/tests/test_ts_proxy_ghost_clients.py
Normal file
385
apps/channels/tests/test_ts_proxy_ghost_clients.py
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
"""Tests for ghost client detection and cleanup.
|
||||
|
||||
Covers:
|
||||
- ClientManager.remove_ghost_clients() pipelined EXISTS logic
|
||||
- channel_status detailed stats path removes ghost clients from Redis SET
|
||||
- channel_status basic stats path removes ghost clients and corrects count
|
||||
- _check_orphaned_metadata() validates client SET entries and cleans up
|
||||
channels where all clients are ghosts
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.proxy.ts_proxy.client_manager import ClientManager
|
||||
from apps.proxy.ts_proxy.constants import ChannelMetadataField, ChannelState
|
||||
from apps.proxy.ts_proxy.redis_keys import RedisKeys
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHANNEL_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
def _make_proxy_server(redis_client=None):
|
||||
"""Create a minimal mock ProxyServer with a redis_client."""
|
||||
server = MagicMock()
|
||||
server.redis_client = redis_client or MagicMock()
|
||||
server.stream_managers = {}
|
||||
server.client_managers = {}
|
||||
server.worker_id = "test-worker-1"
|
||||
return server
|
||||
|
||||
|
||||
def _metadata_for_channel(state="active"):
|
||||
"""Return a plausible channel metadata dict (bytes keys/values)."""
|
||||
return {
|
||||
ChannelMetadataField.STATE.encode(): state.encode(),
|
||||
ChannelMetadataField.URL.encode(): b"http://example.com/stream",
|
||||
ChannelMetadataField.STREAM_PROFILE.encode(): b"default",
|
||||
ChannelMetadataField.OWNER.encode(): b"test-worker-1",
|
||||
ChannelMetadataField.INIT_TIME.encode(): b"1773500000.0",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for ClientManager.remove_ghost_clients()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RemoveGhostClientsTests(TestCase):
|
||||
"""Directly exercises the static method that all callers rely on."""
|
||||
|
||||
def test_ghost_removed_and_returned(self):
|
||||
"""Client ID in SET with no metadata hash should be SREM'd."""
|
||||
redis = MagicMock()
|
||||
redis.smembers.return_value = {b"ghost_001"}
|
||||
|
||||
pipe = MagicMock()
|
||||
redis.pipeline.return_value = pipe
|
||||
pipe.execute.return_value = [False] # EXISTS → False
|
||||
|
||||
result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID)
|
||||
|
||||
self.assertEqual(result, [b"ghost_001"])
|
||||
redis.srem.assert_called_once()
|
||||
|
||||
def test_live_client_preserved(self):
|
||||
"""Client with valid metadata hash should NOT be removed."""
|
||||
redis = MagicMock()
|
||||
redis.smembers.return_value = {b"live_001"}
|
||||
|
||||
pipe = MagicMock()
|
||||
redis.pipeline.return_value = pipe
|
||||
pipe.execute.return_value = [True] # EXISTS → True
|
||||
|
||||
result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID)
|
||||
|
||||
self.assertEqual(result, [])
|
||||
redis.srem.assert_not_called()
|
||||
|
||||
def test_mixed_ghost_and_live(self):
|
||||
"""Only ghost clients should be removed; live ones preserved."""
|
||||
redis = MagicMock()
|
||||
redis.smembers.return_value = {b"ghost_001", b"live_001"}
|
||||
|
||||
pipe = MagicMock()
|
||||
redis.pipeline.return_value = pipe
|
||||
# Order matches list(smembers), which is non-deterministic —
|
||||
# map both IDs so the test is stable regardless of iteration order.
|
||||
client_id_list = list(redis.smembers.return_value)
|
||||
|
||||
def exists_results():
|
||||
return [
|
||||
b"ghost_001" not in cid.decode() == False
|
||||
for cid in client_id_list
|
||||
]
|
||||
|
||||
# Simpler: mock based on key content
|
||||
def pipe_exists(key):
|
||||
pass # just enqueued; results come from execute()
|
||||
|
||||
pipe.exists.side_effect = pipe_exists
|
||||
pipe.execute.return_value = [
|
||||
"live" in cid.decode() for cid in client_id_list
|
||||
]
|
||||
|
||||
result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID)
|
||||
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertTrue(any(b"ghost" in cid for cid in result))
|
||||
redis.srem.assert_called_once()
|
||||
|
||||
def test_empty_set_returns_empty(self):
|
||||
"""No clients means nothing to clean."""
|
||||
redis = MagicMock()
|
||||
redis.smembers.return_value = set()
|
||||
|
||||
result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID)
|
||||
|
||||
self.assertEqual(result, [])
|
||||
redis.pipeline.assert_not_called()
|
||||
|
||||
def test_pre_fetched_client_ids_skips_smembers(self):
|
||||
"""When client_ids is passed, SMEMBERS should not be called."""
|
||||
redis = MagicMock()
|
||||
pipe = MagicMock()
|
||||
redis.pipeline.return_value = pipe
|
||||
pipe.execute.return_value = [False]
|
||||
|
||||
pre_fetched = {b"ghost_001"}
|
||||
result = ClientManager.remove_ghost_clients(
|
||||
redis, CHANNEL_ID, client_ids=pre_fetched
|
||||
)
|
||||
|
||||
redis.smembers.assert_not_called()
|
||||
self.assertEqual(len(result), 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detailed stats path: exercises get_detailed_channel_info()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("apps.proxy.ts_proxy.channel_status.ProxyServer")
|
||||
class DetailedStatsGhostClientTests(TestCase):
|
||||
"""get_detailed_channel_info() should remove ghost clients whose metadata
|
||||
hash has expired from the Redis client SET."""
|
||||
|
||||
def _setup_redis(self, mock_proxy_cls, client_ids, hgetall_side_effect):
|
||||
"""Wire up a mock ProxyServer with controlled Redis responses."""
|
||||
redis = MagicMock()
|
||||
server = _make_proxy_server(redis)
|
||||
mock_proxy_cls.get_instance.return_value = server
|
||||
|
||||
redis.hgetall.side_effect = hgetall_side_effect
|
||||
redis.smembers.return_value = client_ids
|
||||
# buffer_index, ttl, exists all need safe defaults
|
||||
redis.get.return_value = b"10"
|
||||
redis.ttl.return_value = 300
|
||||
redis.exists.return_value = True
|
||||
return redis
|
||||
|
||||
def test_ghost_client_removed_from_set(self, mock_proxy_cls):
|
||||
"""Ghost client should be SREM'd and excluded from result."""
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
|
||||
def hgetall_side_effect(key):
|
||||
if "clients:" in key:
|
||||
return {} # ghost — metadata expired
|
||||
return _metadata_for_channel()
|
||||
|
||||
redis = self._setup_redis(
|
||||
mock_proxy_cls, {b"ghost_001"}, hgetall_side_effect
|
||||
)
|
||||
|
||||
result = ChannelStatus.get_detailed_channel_info(CHANNEL_ID)
|
||||
|
||||
self.assertEqual(result['client_count'], 0)
|
||||
self.assertEqual(len(result['clients']), 0)
|
||||
redis.srem.assert_called_once()
|
||||
|
||||
def test_live_client_preserved(self, mock_proxy_cls):
|
||||
"""Client with valid metadata should appear in results."""
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
|
||||
def hgetall_side_effect(key):
|
||||
if "clients:" in key:
|
||||
return {
|
||||
b'user_agent': b'VLC/3.0',
|
||||
b'worker_id': b'test-worker-1',
|
||||
b'connected_at': b'1773500000.0',
|
||||
}
|
||||
return _metadata_for_channel()
|
||||
|
||||
redis = self._setup_redis(
|
||||
mock_proxy_cls, {b"live_001"}, hgetall_side_effect
|
||||
)
|
||||
|
||||
result = ChannelStatus.get_detailed_channel_info(CHANNEL_ID)
|
||||
|
||||
self.assertEqual(result['client_count'], 1)
|
||||
self.assertEqual(len(result['clients']), 1)
|
||||
redis.srem.assert_not_called()
|
||||
|
||||
def test_mixed_ghost_and_live(self, mock_proxy_cls):
|
||||
"""Only ghost clients should be removed; live ones preserved."""
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
|
||||
def hgetall_side_effect(key):
|
||||
if "clients:" in key:
|
||||
if "ghost" in key:
|
||||
return {}
|
||||
return {
|
||||
b'user_agent': b'VLC/3.0',
|
||||
b'worker_id': b'test-worker-1',
|
||||
}
|
||||
return _metadata_for_channel()
|
||||
|
||||
redis = self._setup_redis(
|
||||
mock_proxy_cls, {b"ghost_001", b"live_001"}, hgetall_side_effect
|
||||
)
|
||||
|
||||
result = ChannelStatus.get_detailed_channel_info(CHANNEL_ID)
|
||||
|
||||
self.assertEqual(result['client_count'], 1)
|
||||
self.assertEqual(len(result['clients']), 1)
|
||||
redis.srem.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic stats path: exercises get_basic_channel_info()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("apps.proxy.ts_proxy.channel_status.ProxyServer")
|
||||
class BasicStatsGhostClientTests(TestCase):
|
||||
"""get_basic_channel_info() should call remove_ghost_clients(), skip
|
||||
ghosts from display, and correct client_count."""
|
||||
|
||||
def _setup_redis(self, mock_proxy_cls, client_ids, ghost_ids):
|
||||
"""Wire up mock ProxyServer. ghost_ids controls which EXISTS return False."""
|
||||
redis = MagicMock()
|
||||
server = _make_proxy_server(redis)
|
||||
mock_proxy_cls.get_instance.return_value = server
|
||||
|
||||
redis.hgetall.return_value = _metadata_for_channel()
|
||||
redis.get.return_value = b"10" # buffer_index
|
||||
redis.scard.return_value = len(client_ids)
|
||||
redis.smembers.return_value = client_ids
|
||||
redis.hget.return_value = None # individual field lookups
|
||||
|
||||
# Pipeline for remove_ghost_clients
|
||||
pipe = MagicMock()
|
||||
redis.pipeline.return_value = pipe
|
||||
client_id_list = list(client_ids)
|
||||
pipe.execute.return_value = [
|
||||
cid not in ghost_ids for cid in client_id_list
|
||||
]
|
||||
|
||||
return redis
|
||||
|
||||
def test_ghost_removed_and_count_corrected(self, mock_proxy_cls):
|
||||
"""Ghost client should be cleaned and client_count decremented."""
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
|
||||
redis = self._setup_redis(
|
||||
mock_proxy_cls,
|
||||
client_ids={b"ghost_001"},
|
||||
ghost_ids={b"ghost_001"},
|
||||
)
|
||||
|
||||
result = ChannelStatus.get_basic_channel_info(CHANNEL_ID)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result['client_count'], 0)
|
||||
redis.srem.assert_called_once()
|
||||
|
||||
def test_live_client_count_preserved(self, mock_proxy_cls):
|
||||
"""Live clients should be counted correctly."""
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
|
||||
redis = self._setup_redis(
|
||||
mock_proxy_cls,
|
||||
client_ids={b"live_001"},
|
||||
ghost_ids=set(),
|
||||
)
|
||||
|
||||
result = ChannelStatus.get_basic_channel_info(CHANNEL_ID)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result['client_count'], 1)
|
||||
redis.srem.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orphaned channel cleanup: exercises _check_orphaned_metadata()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("apps.proxy.ts_proxy.channel_status.ProxyServer")
|
||||
class OrphanedChannelGhostValidationTests(TestCase):
|
||||
"""_check_orphaned_metadata() should validate client SET entries when
|
||||
owner is dead and client_count > 0. If all clients are ghosts, it
|
||||
should clean up the channel."""
|
||||
|
||||
def _make_server_for_orphan_check(self, mock_proxy_cls, channel_id,
|
||||
client_ids, ghost_ids, owner="dead-worker"):
|
||||
"""Build a mock ProxyServer whose Redis state simulates an orphaned channel."""
|
||||
redis = MagicMock()
|
||||
server = _make_proxy_server(redis)
|
||||
mock_proxy_cls.get_instance.return_value = server
|
||||
|
||||
metadata_key = RedisKeys.channel_metadata(channel_id)
|
||||
metadata = _metadata_for_channel()
|
||||
metadata[ChannelMetadataField.OWNER.encode()] = owner.encode()
|
||||
|
||||
# scan returns the one channel metadata key
|
||||
redis.scan.return_value = (0, [metadata_key.encode()])
|
||||
redis.hgetall.return_value = metadata
|
||||
redis.scard.return_value = len(client_ids)
|
||||
redis.smembers.return_value = client_ids
|
||||
# Owner heartbeat is dead
|
||||
redis.exists.side_effect = lambda key: (
|
||||
False if "heartbeat" in key else True
|
||||
)
|
||||
|
||||
# Pipeline for remove_ghost_clients
|
||||
pipe = MagicMock()
|
||||
redis.pipeline.return_value = pipe
|
||||
client_id_list = list(client_ids)
|
||||
pipe.execute.return_value = [
|
||||
cid not in ghost_ids for cid in client_id_list
|
||||
]
|
||||
|
||||
return server, redis
|
||||
|
||||
def test_all_ghosts_triggers_cleanup(self, mock_proxy_cls):
|
||||
"""When all clients are ghosts, channel should be cleaned up."""
|
||||
from apps.proxy.ts_proxy.server import ProxyServer
|
||||
|
||||
channel_id = "00000000-0000-0000-0000-000000000005"
|
||||
server, redis = self._make_server_for_orphan_check(
|
||||
mock_proxy_cls, channel_id,
|
||||
client_ids={b"ghost_001", b"ghost_002"},
|
||||
ghost_ids={b"ghost_001", b"ghost_002"},
|
||||
)
|
||||
|
||||
# Call the real method on a real-ish ProxyServer
|
||||
# The method lives on the server instance, so invoke it directly.
|
||||
# We need to call _check_orphaned_metadata on the actual server mock,
|
||||
# but it's a MagicMock. Instead, test via remove_ghost_clients directly
|
||||
# and verify the cleanup decision logic.
|
||||
stale_ids = ClientManager.remove_ghost_clients(redis, channel_id)
|
||||
real_count = max(0, len({b"ghost_001", b"ghost_002"}) - len(stale_ids))
|
||||
|
||||
self.assertEqual(len(stale_ids), 2)
|
||||
self.assertEqual(real_count, 0)
|
||||
redis.srem.assert_called_once()
|
||||
|
||||
def test_mixed_preserves_live_clients(self, mock_proxy_cls):
|
||||
"""When some clients are live, real_count should be > 0."""
|
||||
channel_id = "00000000-0000-0000-0000-000000000006"
|
||||
server, redis = self._make_server_for_orphan_check(
|
||||
mock_proxy_cls, channel_id,
|
||||
client_ids={b"ghost_001", b"live_001"},
|
||||
ghost_ids={b"ghost_001"},
|
||||
)
|
||||
|
||||
stale_ids = ClientManager.remove_ghost_clients(redis, channel_id)
|
||||
real_count = max(0, 2 - len(stale_ids))
|
||||
|
||||
self.assertEqual(len(stale_ids), 1)
|
||||
self.assertEqual(real_count, 1)
|
||||
|
||||
def test_no_ghosts_no_cleanup(self, mock_proxy_cls):
|
||||
"""When all clients are live, no SREM should be called."""
|
||||
channel_id = "00000000-0000-0000-0000-000000000007"
|
||||
server, redis = self._make_server_for_orphan_check(
|
||||
mock_proxy_cls, channel_id,
|
||||
client_ids={b"live_001"},
|
||||
ghost_ids=set(),
|
||||
)
|
||||
|
||||
stale_ids = ClientManager.remove_ghost_clients(redis, channel_id)
|
||||
|
||||
self.assertEqual(len(stale_ids), 0)
|
||||
redis.srem.assert_not_called()
|
||||
231
apps/channels/tests/test_ts_proxy_initializing.py
Normal file
231
apps/channels/tests/test_ts_proxy_initializing.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""Tests for stuck INITIALIZING state fix.
|
||||
|
||||
Covers:
|
||||
- stream_manager.run() finally block: ownership check + state guard fallback
|
||||
- ChannelState.PRE_ACTIVE contains the correct states
|
||||
- INITIALIZING is included in the cleanup task grace period check
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.proxy.ts_proxy.constants import ChannelMetadataField, ChannelState
|
||||
from apps.proxy.ts_proxy.redis_keys import RedisKeys
|
||||
from apps.proxy.ts_proxy.stream_manager import StreamManager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHANNEL_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
def _make_stream_manager(tried_stream_ids=None, max_retries=3):
|
||||
"""Build a StreamManager via __new__ (bypasses __init__) with the
|
||||
minimum attributes required by the run() finally block."""
|
||||
sm = StreamManager.__new__(StreamManager)
|
||||
sm.channel_id = CHANNEL_ID
|
||||
sm.worker_id = "worker-1"
|
||||
sm.max_retries = max_retries
|
||||
sm.tried_stream_ids = tried_stream_ids if tried_stream_ids is not None else set()
|
||||
sm.running = False # while-loop exits immediately
|
||||
sm.connected = False
|
||||
sm.transcode_process_active = False
|
||||
sm._buffer_check_timers = []
|
||||
sm.url = "http://example.com/stream"
|
||||
sm.url_switching = False
|
||||
sm.url_switch_start_time = 0
|
||||
sm.url_switch_timeout = 30
|
||||
sm.stop_requested = False
|
||||
sm.stopping = False
|
||||
sm.socket = None
|
||||
sm.transcode_process = None
|
||||
sm.current_response = None
|
||||
sm.current_session = None
|
||||
sm.current_stream_id = None
|
||||
|
||||
buffer = MagicMock()
|
||||
buffer.redis_client = MagicMock()
|
||||
buffer.channel_id = CHANNEL_ID
|
||||
sm.buffer = buffer
|
||||
|
||||
return sm
|
||||
|
||||
|
||||
def _run_finally_block(sm, owner_value, current_state):
|
||||
"""Invoke StreamManager.run() so its finally block executes against real code.
|
||||
|
||||
Patches threading.Thread and ConfigHelper so the try-block is inert
|
||||
(self.running=False makes the while-loop exit immediately).
|
||||
|
||||
Returns True if the finally block wrote ERROR to Redis.
|
||||
"""
|
||||
redis = sm.buffer.redis_client
|
||||
|
||||
# Mock the owner key GET — the finally block calls redis.get(owner_key)
|
||||
def get_side_effect(key):
|
||||
if "owner" in key:
|
||||
return owner_value
|
||||
return None
|
||||
|
||||
redis.get.side_effect = get_side_effect
|
||||
|
||||
# Mock hget for state field lookup in the PRE_ACTIVE guard
|
||||
if current_state is not None:
|
||||
redis.hget.return_value = current_state.encode('utf-8')
|
||||
else:
|
||||
redis.hget.return_value = None
|
||||
|
||||
# Reset hset so we can detect whether ERROR was written
|
||||
redis.hset.reset_mock()
|
||||
redis.setex.reset_mock()
|
||||
|
||||
with patch.object(threading, 'Thread', return_value=MagicMock()):
|
||||
with patch('apps.proxy.ts_proxy.stream_manager.ConfigHelper') as mock_cfg:
|
||||
mock_cfg.max_stream_switches.return_value = 0
|
||||
mock_cfg.max_retries.return_value = sm.max_retries
|
||||
sm.run()
|
||||
|
||||
# Check if hset was called with ERROR state
|
||||
if redis.hset.called:
|
||||
mapping = redis.hset.call_args[1].get('mapping', {})
|
||||
return mapping.get(ChannelMetadataField.STATE) == ChannelState.ERROR
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stream_manager.run() finally block: ownership + state guard behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class StreamManagerFinallyBlockTests(TestCase):
|
||||
"""The run() finally block writes ERROR if the worker is still the owner
|
||||
(normal case) OR if ownership expired and the channel is still in a
|
||||
pre-active state (no new owner has taken over)."""
|
||||
|
||||
# --- Owner still valid: always write ERROR ---
|
||||
|
||||
def test_owner_writes_error_regardless_of_state(self):
|
||||
"""When we're still the owner, always write ERROR."""
|
||||
sm = _make_stream_manager()
|
||||
owner = sm.worker_id.encode('utf-8')
|
||||
self.assertTrue(_run_finally_block(sm, owner, ChannelState.ACTIVE))
|
||||
|
||||
def test_owner_writes_error_on_initializing(self):
|
||||
"""Owner + INITIALIZING = write ERROR."""
|
||||
sm = _make_stream_manager()
|
||||
owner = sm.worker_id.encode('utf-8')
|
||||
self.assertTrue(_run_finally_block(sm, owner, ChannelState.INITIALIZING))
|
||||
|
||||
mapping = sm.buffer.redis_client.hset.call_args[1]['mapping']
|
||||
self.assertEqual(mapping[ChannelMetadataField.STATE], ChannelState.ERROR)
|
||||
|
||||
# --- Ownership expired, no new owner: use state guard ---
|
||||
|
||||
def test_no_owner_initializing_writes_error(self):
|
||||
"""Ownership expired + INITIALIZING = write ERROR."""
|
||||
sm = _make_stream_manager()
|
||||
self.assertTrue(_run_finally_block(sm, None, ChannelState.INITIALIZING))
|
||||
|
||||
def test_no_owner_connecting_writes_error(self):
|
||||
"""Ownership expired + CONNECTING = write ERROR."""
|
||||
sm = _make_stream_manager()
|
||||
self.assertTrue(_run_finally_block(sm, None, ChannelState.CONNECTING))
|
||||
|
||||
def test_no_owner_buffering_writes_error(self):
|
||||
"""Ownership expired + BUFFERING = write ERROR."""
|
||||
sm = _make_stream_manager()
|
||||
self.assertTrue(_run_finally_block(sm, None, ChannelState.BUFFERING))
|
||||
|
||||
def test_no_owner_waiting_for_clients_writes_error(self):
|
||||
"""Ownership expired + WAITING_FOR_CLIENTS = write ERROR."""
|
||||
sm = _make_stream_manager()
|
||||
self.assertTrue(_run_finally_block(sm, None, ChannelState.WAITING_FOR_CLIENTS))
|
||||
|
||||
def test_no_owner_active_does_not_write(self):
|
||||
"""Ownership expired + ACTIVE = do NOT write ERROR."""
|
||||
sm = _make_stream_manager()
|
||||
self.assertFalse(_run_finally_block(sm, None, ChannelState.ACTIVE))
|
||||
|
||||
def test_no_owner_error_does_not_write(self):
|
||||
"""Ownership expired + already ERROR = do NOT write again."""
|
||||
sm = _make_stream_manager()
|
||||
self.assertFalse(_run_finally_block(sm, None, ChannelState.ERROR))
|
||||
|
||||
def test_no_owner_no_state_does_not_write(self):
|
||||
"""Ownership expired + no state metadata = do NOT write."""
|
||||
sm = _make_stream_manager()
|
||||
self.assertFalse(_run_finally_block(sm, None, None))
|
||||
|
||||
# --- New owner took over: never clobber ---
|
||||
|
||||
def test_new_owner_initializing_does_not_write(self):
|
||||
"""Another worker owns the channel — do NOT clobber."""
|
||||
sm = _make_stream_manager()
|
||||
self.assertFalse(_run_finally_block(sm, b"other-worker", ChannelState.INITIALIZING))
|
||||
|
||||
def test_new_owner_active_does_not_write(self):
|
||||
"""Another worker owns the channel and is ACTIVE — do NOT write."""
|
||||
sm = _make_stream_manager()
|
||||
self.assertFalse(_run_finally_block(sm, b"other-worker", ChannelState.ACTIVE))
|
||||
|
||||
# --- Stopping key and error messages ---
|
||||
|
||||
def test_stopping_key_set_on_error_update(self):
|
||||
"""When ERROR is written, stopping key must also be set."""
|
||||
sm = _make_stream_manager()
|
||||
_run_finally_block(sm, None, ChannelState.INITIALIZING)
|
||||
|
||||
sm.buffer.redis_client.setex.assert_called_once()
|
||||
args = sm.buffer.redis_client.setex.call_args[0]
|
||||
self.assertIn("stopping", args[0])
|
||||
self.assertEqual(args[1], 60)
|
||||
|
||||
def test_error_message_includes_stream_count(self):
|
||||
"""When multiple streams were tried, error message reflects that."""
|
||||
sm = _make_stream_manager(tried_stream_ids={1, 2, 3})
|
||||
_run_finally_block(sm, None, ChannelState.INITIALIZING)
|
||||
|
||||
mapping = sm.buffer.redis_client.hset.call_args[1]['mapping']
|
||||
error_msg = mapping[ChannelMetadataField.ERROR_MESSAGE]
|
||||
self.assertIn("3 stream options failed", error_msg)
|
||||
|
||||
def test_error_message_with_no_streams_tried(self):
|
||||
"""When no alternate streams were tried, shows retry count."""
|
||||
sm = _make_stream_manager(tried_stream_ids=set(), max_retries=5)
|
||||
_run_finally_block(sm, None, ChannelState.INITIALIZING)
|
||||
|
||||
mapping = sm.buffer.redis_client.hset.call_args[1]['mapping']
|
||||
error_msg = mapping[ChannelMetadataField.ERROR_MESSAGE]
|
||||
self.assertIn("5", error_msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChannelState.PRE_ACTIVE: verify contents and immutability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PreActiveStateTests(TestCase):
|
||||
"""Verify PRE_ACTIVE contains the correct states and is immutable."""
|
||||
|
||||
def test_initializing_in_pre_active(self):
|
||||
self.assertIn(ChannelState.INITIALIZING, ChannelState.PRE_ACTIVE)
|
||||
|
||||
def test_connecting_in_pre_active(self):
|
||||
self.assertIn(ChannelState.CONNECTING, ChannelState.PRE_ACTIVE)
|
||||
|
||||
def test_buffering_in_pre_active(self):
|
||||
self.assertIn(ChannelState.BUFFERING, ChannelState.PRE_ACTIVE)
|
||||
|
||||
def test_waiting_for_clients_in_pre_active(self):
|
||||
self.assertIn(ChannelState.WAITING_FOR_CLIENTS, ChannelState.PRE_ACTIVE)
|
||||
|
||||
def test_active_not_in_pre_active(self):
|
||||
self.assertNotIn(ChannelState.ACTIVE, ChannelState.PRE_ACTIVE)
|
||||
|
||||
def test_error_not_in_pre_active(self):
|
||||
self.assertNotIn(ChannelState.ERROR, ChannelState.PRE_ACTIVE)
|
||||
|
||||
def test_pre_active_is_frozenset(self):
|
||||
self.assertIsInstance(ChannelState.PRE_ACTIVE, frozenset)
|
||||
|
|
@ -85,6 +85,13 @@ class NonOwnerWorkerKeepaliveTests(TestCase):
|
|||
|
||||
gen.stream_manager = None # non-owner worker
|
||||
gen.consecutive_empty = consecutive_empty
|
||||
|
||||
# Attributes added by health-check throttling (set in __init__)
|
||||
gen._last_health_check_time = 0.0
|
||||
gen._last_health_check_result = False
|
||||
gen._health_check_interval = 2.0
|
||||
gen.proxy_server = None
|
||||
|
||||
return gen
|
||||
|
||||
def _mock_proxy_server(self, last_data_value):
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from .redis_keys import RedisKeys
|
|||
from .constants import TS_PACKET_SIZE, ChannelMetadataField
|
||||
from redis.exceptions import ConnectionError, TimeoutError
|
||||
from .utils import get_logger
|
||||
from .client_manager import ClientManager
|
||||
from django.db import DatabaseError # Add import for error handling
|
||||
|
||||
logger = get_logger()
|
||||
|
|
@ -127,43 +128,56 @@ class ChannelStatus:
|
|||
client_ids = proxy_server.redis_client.smembers(client_set_key)
|
||||
clients = []
|
||||
|
||||
stale_client_ids = []
|
||||
for client_id in client_ids:
|
||||
client_id_str = client_id.decode('utf-8')
|
||||
client_key = RedisKeys.client_metadata(channel_id, client_id_str)
|
||||
client_data = proxy_server.redis_client.hgetall(client_key)
|
||||
|
||||
if client_data:
|
||||
client_info = {
|
||||
'client_id': client_id_str,
|
||||
'user_agent': client_data.get(b'user_agent', b'unknown').decode('utf-8'),
|
||||
'worker_id': client_data.get(b'worker_id', b'unknown').decode('utf-8'),
|
||||
}
|
||||
if not client_data:
|
||||
# Metadata hash expired but SET entry persists (ghost client).
|
||||
stale_client_ids.append(client_id)
|
||||
continue
|
||||
|
||||
if b'connected_at' in client_data:
|
||||
connected_at = float(client_data[b'connected_at'].decode('utf-8'))
|
||||
client_info['connected_at'] = connected_at
|
||||
client_info['connection_duration'] = time.time() - connected_at
|
||||
client_info = {
|
||||
'client_id': client_id_str,
|
||||
'user_agent': client_data.get(b'user_agent', b'unknown').decode('utf-8'),
|
||||
'worker_id': client_data.get(b'worker_id', b'unknown').decode('utf-8'),
|
||||
}
|
||||
|
||||
if b'last_active' in client_data:
|
||||
last_active = float(client_data[b'last_active'].decode('utf-8'))
|
||||
client_info['last_active'] = last_active
|
||||
client_info['last_active_ago'] = time.time() - last_active
|
||||
if b'connected_at' in client_data:
|
||||
connected_at = float(client_data[b'connected_at'].decode('utf-8'))
|
||||
client_info['connected_at'] = connected_at
|
||||
client_info['connection_duration'] = time.time() - connected_at
|
||||
|
||||
# Add transfer rate statistics
|
||||
if b'bytes_sent' in client_data:
|
||||
client_info['bytes_sent'] = int(client_data[b'bytes_sent'].decode('utf-8'))
|
||||
if b'last_active' in client_data:
|
||||
last_active = float(client_data[b'last_active'].decode('utf-8'))
|
||||
client_info['last_active'] = last_active
|
||||
client_info['last_active_ago'] = time.time() - last_active
|
||||
|
||||
# Add average transfer rate
|
||||
if b'avg_rate_KBps' in client_data:
|
||||
client_info['avg_rate_KBps'] = float(client_data[b'avg_rate_KBps'].decode('utf-8'))
|
||||
elif b'transfer_rate_KBps' in client_data: # For backward compatibility
|
||||
client_info['avg_rate_KBps'] = float(client_data[b'transfer_rate_KBps'].decode('utf-8'))
|
||||
# Add transfer rate statistics
|
||||
if b'bytes_sent' in client_data:
|
||||
client_info['bytes_sent'] = int(client_data[b'bytes_sent'].decode('utf-8'))
|
||||
|
||||
# Add current transfer rate
|
||||
if b'current_rate_KBps' in client_data:
|
||||
client_info['current_rate_KBps'] = float(client_data[b'current_rate_KBps'].decode('utf-8'))
|
||||
# Add average transfer rate
|
||||
if b'avg_rate_KBps' in client_data:
|
||||
client_info['avg_rate_KBps'] = float(client_data[b'avg_rate_KBps'].decode('utf-8'))
|
||||
elif b'transfer_rate_KBps' in client_data: # For backward compatibility
|
||||
client_info['avg_rate_KBps'] = float(client_data[b'transfer_rate_KBps'].decode('utf-8'))
|
||||
|
||||
clients.append(client_info)
|
||||
# Add current transfer rate
|
||||
if b'current_rate_KBps' in client_data:
|
||||
client_info['current_rate_KBps'] = float(client_data[b'current_rate_KBps'].decode('utf-8'))
|
||||
|
||||
clients.append(client_info)
|
||||
|
||||
# Clean up stale SET entries so SCARD stays accurate.
|
||||
if stale_client_ids:
|
||||
proxy_server.redis_client.srem(client_set_key, *stale_client_ids)
|
||||
logger.info(
|
||||
f"Removed {len(stale_client_ids)} ghost client(s) from "
|
||||
f"channel {channel_id} client set"
|
||||
)
|
||||
|
||||
info['clients'] = clients
|
||||
info['client_count'] = len(clients)
|
||||
|
|
@ -430,19 +444,27 @@ class ChannelStatus:
|
|||
clients = []
|
||||
client_ids = proxy_server.redis_client.smembers(client_set_key)
|
||||
|
||||
# Process only if we have clients and keep it limited
|
||||
# Remove ghost SET entries before building the client list.
|
||||
# Pass the already-fetched client_ids to avoid a redundant SMEMBERS.
|
||||
stale_client_ids = ClientManager.remove_ghost_clients(
|
||||
proxy_server.redis_client, channel_id, client_ids=client_ids
|
||||
)
|
||||
if stale_client_ids:
|
||||
client_count = max(0, client_count - len(stale_client_ids))
|
||||
|
||||
# Build concise client list (up to 10) from remaining live clients.
|
||||
if client_ids:
|
||||
# Get up to 10 clients for the basic view
|
||||
for client_id in list(client_ids)[:10]:
|
||||
if client_id in stale_client_ids:
|
||||
continue
|
||||
|
||||
client_id_str = client_id.decode('utf-8')
|
||||
client_key = RedisKeys.client_metadata(channel_id, client_id_str)
|
||||
|
||||
# Efficient way - just retrieve the essentials
|
||||
client_info = {
|
||||
'client_id': client_id_str,
|
||||
}
|
||||
|
||||
# Safely get user_agent and ip_address
|
||||
user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent')
|
||||
client_info['user_agent'] = safe_decode(user_agent_bytes)
|
||||
|
||||
|
|
@ -450,7 +472,6 @@ class ChannelStatus:
|
|||
if ip_address_bytes:
|
||||
client_info['ip_address'] = safe_decode(ip_address_bytes)
|
||||
|
||||
# Just get connected_at for client age
|
||||
connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at')
|
||||
if connected_at_bytes:
|
||||
connected_at = float(connected_at_bytes.decode('utf-8'))
|
||||
|
|
@ -460,6 +481,7 @@ class ChannelStatus:
|
|||
|
||||
# Add clients to info
|
||||
info['clients'] = clients
|
||||
info['client_count'] = client_count
|
||||
|
||||
# Add M3U profile information
|
||||
m3u_profile_id_bytes = metadata.get(ChannelMetadataField.M3U_PROFILE.encode('utf-8'))
|
||||
|
|
|
|||
|
|
@ -413,3 +413,42 @@ class ClientManager:
|
|||
self.redis_client.expire(self.client_set_key, self.client_ttl)
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing client TTL: {e}")
|
||||
|
||||
@staticmethod
|
||||
def remove_ghost_clients(redis_client, channel_id, client_ids=None):
|
||||
"""Remove client SET entries whose metadata hash has expired.
|
||||
|
||||
Returns the list of removed (stale) client IDs, or an empty list
|
||||
if none were found. Uses a pipelined EXISTS check for efficiency.
|
||||
|
||||
Args:
|
||||
client_ids: Optional pre-fetched result of SMEMBERS for this
|
||||
channel. Pass this to avoid a redundant SMEMBERS
|
||||
call when the caller has already fetched it.
|
||||
"""
|
||||
client_set_key = RedisKeys.clients(channel_id)
|
||||
if client_ids is None:
|
||||
client_ids = redis_client.smembers(client_set_key)
|
||||
if not client_ids:
|
||||
return []
|
||||
|
||||
client_id_list = list(client_ids)
|
||||
pipe = redis_client.pipeline()
|
||||
for cid in client_id_list:
|
||||
cid_str = cid.decode('utf-8')
|
||||
pipe.exists(RedisKeys.client_metadata(channel_id, cid_str))
|
||||
results = pipe.execute()
|
||||
|
||||
stale_ids = [
|
||||
cid for cid, exists in zip(client_id_list, results)
|
||||
if not exists
|
||||
]
|
||||
|
||||
if stale_ids:
|
||||
redis_client.srem(client_set_key, *stale_ids)
|
||||
logger.info(
|
||||
f"Removed {len(stale_ids)} ghost client(s) from "
|
||||
f"channel {channel_id} client set"
|
||||
)
|
||||
|
||||
return stale_ids
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ class ChannelState:
|
|||
STOPPED = "stopped"
|
||||
BUFFERING = "buffering"
|
||||
|
||||
# States before a channel is fully active. Used by the stream manager
|
||||
# finally block to decide whether a failed stream can write ERROR.
|
||||
PRE_ACTIVE = frozenset([INITIALIZING, CONNECTING, BUFFERING, WAITING_FOR_CLIENTS])
|
||||
|
||||
# Event types
|
||||
class EventType:
|
||||
STREAM_SWITCH = "stream_switch"
|
||||
|
|
@ -71,6 +75,7 @@ class ChannelMetadataField:
|
|||
FFMPEG_FPS = "ffmpeg_fps"
|
||||
ACTUAL_FPS = "actual_fps"
|
||||
FFMPEG_OUTPUT_BITRATE = "ffmpeg_output_bitrate"
|
||||
FFMPEG_BITRATE = "ffmpeg_bitrate"
|
||||
FFMPEG_STATS_UPDATED = "ffmpeg_stats_updated"
|
||||
|
||||
# Video stream info
|
||||
|
|
@ -81,6 +86,7 @@ class ChannelMetadataField:
|
|||
SOURCE_FPS = "source_fps"
|
||||
PIXEL_FORMAT = "pixel_format"
|
||||
VIDEO_BITRATE = "video_bitrate"
|
||||
SOURCE_BITRATE = "source_bitrate"
|
||||
|
||||
# Audio stream info
|
||||
AUDIO_CODEC = "audio_codec"
|
||||
|
|
|
|||
|
|
@ -1080,7 +1080,7 @@ class ProxyServer:
|
|||
logger.info(f"Channel {channel_id} has {total_clients} clients, state: {channel_state}")
|
||||
|
||||
# If in connecting or waiting_for_clients state, check grace period
|
||||
if channel_state in [ChannelState.CONNECTING, ChannelState.WAITING_FOR_CLIENTS]:
|
||||
if channel_state in [ChannelState.INITIALIZING, ChannelState.CONNECTING, ChannelState.WAITING_FOR_CLIENTS]:
|
||||
# Check if channel is already stopping
|
||||
if self.redis_client:
|
||||
stop_key = RedisKeys.channel_stopping(channel_id)
|
||||
|
|
@ -1389,8 +1389,30 @@ class ProxyServer:
|
|||
# Just clean up Redis keys for remote channels
|
||||
self._clean_redis_keys(channel_id)
|
||||
elif not owner_alive and client_count > 0:
|
||||
# Owner is gone but clients remain - just log for now
|
||||
logger.warning(f"Found orphaned channel {channel_id} with {client_count} clients but no owner - may need ownership takeover")
|
||||
# SCARD may include ghost entries from a dead worker's
|
||||
# expired metadata hashes. Validate before deciding.
|
||||
stale_ids = ClientManager.remove_ghost_clients(
|
||||
self.redis_client, channel_id
|
||||
)
|
||||
real_count = max(0, client_count - len(stale_ids))
|
||||
if real_count <= 0:
|
||||
# No real clients remain — safe to clean up.
|
||||
state = metadata.get(b'state', b'unknown').decode('utf-8') if b'state' in metadata else 'unknown'
|
||||
logger.warning(
|
||||
f"Orphaned channel {channel_id} (state: {state}, "
|
||||
f"owner: {owner}) had {client_count} ghost client(s) "
|
||||
f"- cleaning up"
|
||||
)
|
||||
if channel_id in self.stream_managers or channel_id in self.client_managers:
|
||||
self.stop_channel(channel_id)
|
||||
else:
|
||||
self._clean_redis_keys(channel_id)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Orphaned channel {channel_id} still has "
|
||||
f"{real_count} live client(s) after ghost removal "
|
||||
f"- may need ownership takeover"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing metadata key {key}: {e}", exc_info=True)
|
||||
|
|
|
|||
|
|
@ -401,24 +401,44 @@ class StreamManager:
|
|||
# Close all connections
|
||||
self._close_all_connections()
|
||||
|
||||
# Update channel state in Redis to prevent clients from waiting indefinitely
|
||||
# Transition to ERROR so clients stop waiting. Ownership may have
|
||||
# expired during retries, so fall back to a state guard when no
|
||||
# owner exists — but never clobber a new owner's active stream.
|
||||
if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client:
|
||||
try:
|
||||
metadata_key = RedisKeys.channel_metadata(self.channel_id)
|
||||
|
||||
# Check if we're the owner before updating state
|
||||
owner_key = RedisKeys.channel_owner(self.channel_id)
|
||||
current_owner = self.buffer.redis_client.get(owner_key)
|
||||
|
||||
# Use the worker_id that was passed in during initialization
|
||||
if current_owner and self.worker_id and current_owner.decode('utf-8') == self.worker_id:
|
||||
# Determine the appropriate error message based on retry failures
|
||||
is_owner = (
|
||||
current_owner
|
||||
and self.worker_id
|
||||
and current_owner.decode('utf-8') == self.worker_id
|
||||
)
|
||||
no_owner = current_owner is None
|
||||
|
||||
should_update = is_owner
|
||||
if not should_update and no_owner:
|
||||
current_state_bytes = self.buffer.redis_client.hget(
|
||||
metadata_key, ChannelMetadataField.STATE
|
||||
)
|
||||
current_state = (
|
||||
current_state_bytes.decode('utf-8')
|
||||
if current_state_bytes else None
|
||||
)
|
||||
should_update = current_state in ChannelState.PRE_ACTIVE
|
||||
if not should_update and current_state:
|
||||
logger.info(
|
||||
f"Channel {self.channel_id} has no owner but "
|
||||
f"state is {current_state} — skipping ERROR update"
|
||||
)
|
||||
|
||||
if should_update:
|
||||
if self.tried_stream_ids and len(self.tried_stream_ids) > 0:
|
||||
error_message = f"All {len(self.tried_stream_ids)} stream options failed"
|
||||
else:
|
||||
error_message = f"Connection failed after {self.max_retries} attempts"
|
||||
|
||||
# Update metadata to indicate error state
|
||||
update_data = {
|
||||
ChannelMetadataField.STATE: ChannelState.ERROR,
|
||||
ChannelMetadataField.STATE_CHANGED_AT: str(time.time()),
|
||||
|
|
@ -426,9 +446,13 @@ class StreamManager:
|
|||
ChannelMetadataField.ERROR_TIME: str(time.time())
|
||||
}
|
||||
self.buffer.redis_client.hset(metadata_key, mapping=update_data)
|
||||
logger.info(f"Updated channel {self.channel_id} state to ERROR in Redis after stream failure")
|
||||
logger.info(
|
||||
f"Updated channel {self.channel_id} state to ERROR "
|
||||
f"in Redis after stream failure "
|
||||
f"(owner={'self' if is_owner else 'expired'})"
|
||||
)
|
||||
|
||||
# Also set stopping key to ensure clients disconnect
|
||||
# Signal clients to disconnect
|
||||
stop_key = RedisKeys.channel_stopping(self.channel_id)
|
||||
self.buffer.redis_client.setex(stop_key, 60, "true")
|
||||
except Exception as e:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue