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:
SergeantPanda 2026-03-15 20:07:44 -05:00 committed by GitHub
commit cd0eba9654
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 779 additions and 43 deletions

View 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()

View 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)

View file

@ -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):

View file

@ -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'))

View file

@ -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

View file

@ -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"

View file

@ -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)

View file

@ -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: