mirror of
https://github.com/euzu/tuliprox.git
synced 2026-09-27 03:22:36 +02:00
8807 lines
361 KiB
Rust
8807 lines
361 KiB
Rust
use crate::{
|
|
api::model::{active_provider_manager::ConnectionKind, ActiveProviderManager, CustomVideoStreamType, EventManager, EventMessage},
|
|
auth::Fingerprint,
|
|
model::{Config, ProxyUserCredentials},
|
|
utils::{debug_if_enabled, GeoIp},
|
|
};
|
|
use arc_swap::ArcSwapOption;
|
|
use jsonwebtoken::get_current_timestamp;
|
|
use log::{debug, info};
|
|
use shared::{
|
|
model::{
|
|
ActiveUserConnectionChange, PlaylistItemType, StreamChannel, StreamInfo, StreamTechnicalInfo,
|
|
UserConnectionPermission, VirtualId,
|
|
},
|
|
utils::{
|
|
current_time_secs,
|
|
extract_extension_from_url, sanitize_sensitive_info, strip_port, Internable,
|
|
},
|
|
defaults::{
|
|
default_grace_period_millis, default_grace_period_timeout_secs, default_hls_session_ttl_secs,
|
|
DASH_EXT, HLS_EXT,
|
|
}
|
|
};
|
|
use std::{
|
|
borrow::Cow,
|
|
cmp::Reverse,
|
|
collections::{BinaryHeap, HashMap, HashSet},
|
|
net::SocketAddr,
|
|
num::NonZeroUsize,
|
|
sync::{
|
|
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
|
|
Arc,
|
|
},
|
|
time::{Duration, Instant},
|
|
};
|
|
use lru::LruCache;
|
|
use tokio::sync::{mpsc, Mutex, Notify, RwLock};
|
|
use crate::api::model::connection_manager::CleanupEvent;
|
|
use tokio_util::sync::CancellationToken;
|
|
use crate::utils::utc_day_from_secs;
|
|
|
|
const USER_GC_TTL: u64 = 900; // 15 Min
|
|
const USER_CON_TTL: u64 = 1_800; // 30 minutes
|
|
const USER_SESSION_LIMIT: usize = 50;
|
|
const ANON_SOCKET_TTL: u64 = 300; // 5 Min
|
|
const DEFAULT_ACTIVE_SOCKET_TTL_SECS: u64 = 90;
|
|
|
|
fn get_grace_options(config: &Config) -> (u64, u64) {
|
|
let (grace_period_millis, grace_period_timeout_secs) =
|
|
config.reverse_proxy.as_ref().and_then(|r| r.stream.as_ref()).map_or_else(
|
|
|| (default_grace_period_millis(), default_grace_period_timeout_secs()),
|
|
|s| (s.grace_period_millis, s.grace_period_timeout_secs),
|
|
);
|
|
(grace_period_millis, grace_period_timeout_secs)
|
|
}
|
|
|
|
fn get_adaptive_session_ttl_secs(config: &Config) -> u64 {
|
|
config
|
|
.reverse_proxy
|
|
.as_ref()
|
|
.and_then(|r| r.stream.as_ref())
|
|
.map_or_else(default_hls_session_ttl_secs, |s| s.hls_session_ttl_secs)
|
|
}
|
|
|
|
fn stream_history_session_id(ts: u64, uid: u32) -> u64 {
|
|
(ts << 32) | u64::from(uid)
|
|
}
|
|
|
|
fn decide_connection_kind(
|
|
counts: UserConnectionCounts,
|
|
max_connections: u32,
|
|
soft_connections: u16,
|
|
) -> Option<ConnectionKind> {
|
|
if max_connections == 0 || counts.normal < max_connections {
|
|
return Some(ConnectionKind::Normal);
|
|
}
|
|
if soft_connections > 0 && counts.soft < soft_connections {
|
|
return Some(ConnectionKind::Soft);
|
|
}
|
|
None
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum PendingProviderReason {
|
|
GraceHold,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum PendingProviderWakeSource {
|
|
Activated,
|
|
Timeout,
|
|
CapacityNotify,
|
|
Cancelled,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct PendingProviderState {
|
|
pub reason_code: PendingProviderReason,
|
|
pub created_at: u64,
|
|
pub deadline: u64,
|
|
pub version: u64,
|
|
pub wake_source: Option<PendingProviderWakeSource>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub enum PlaybackLifecycle {
|
|
#[default]
|
|
Prepared,
|
|
/// Waiting for a provider slot (`GraceMode::Hold`). The `data` field holds the pending state.
|
|
PendingProvider { data: PendingProviderState },
|
|
Active,
|
|
/// Provisional counted state for `GraceMode::Instant`. Counts against limits immediately
|
|
/// while the grace window resolves (success -> Active, failure -> Expired).
|
|
GraceActive,
|
|
Preserved,
|
|
Expired,
|
|
}
|
|
|
|
impl PlaybackLifecycle {
|
|
/// Returns true for lifecycle states that own a counted admission lease.
|
|
/// Both `Active` and `GraceActive` count — `GraceActive` is a provisional
|
|
/// counted state for `GraceMode::Instant` sessions.
|
|
pub fn is_counted(&self) -> bool {
|
|
matches!(self, Self::Active | Self::GraceActive)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct UserSession {
|
|
pub token: String,
|
|
pub transition_version: u64,
|
|
pub virtual_id: u32,
|
|
pub provider: Arc<str>,
|
|
pub stream_url: Arc<str>,
|
|
pub provider_session_headers: HashMap<String, String>,
|
|
pub addr: SocketAddr,
|
|
pub socket_bound: bool,
|
|
pub active_addrs: Vec<SocketAddr>,
|
|
pub ts: u64,
|
|
pub started_at: u64,
|
|
pub permission: UserConnectionPermission,
|
|
pub connection_kind: Option<ConnectionKind>,
|
|
pub lifecycle: PlaybackLifecycle,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
struct UserConnectionCounts {
|
|
normal: u32,
|
|
soft: u16,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) struct ConnectionAdmission {
|
|
pub(crate) permission: UserConnectionPermission,
|
|
pub(crate) kind: Option<ConnectionKind>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct PromotionAction {
|
|
addr: SocketAddr,
|
|
uid: u32,
|
|
new_priority: i8,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct UserConnectionData {
|
|
max_connections: u32,
|
|
soft_connections: u16,
|
|
counts: UserConnectionCounts,
|
|
connections: u32,
|
|
granted_grace: bool,
|
|
grace_ts: u64,
|
|
sessions: Vec<UserSession>,
|
|
streams: Vec<StreamInfo>,
|
|
stream_kinds: HashMap<u32, ConnectionKind>,
|
|
stream_normal_priorities: HashMap<u32, i8>,
|
|
ts: u64,
|
|
}
|
|
|
|
impl UserConnectionData {
|
|
fn new(connections: u32, max_connections: u32, soft_connections: u16) -> Self {
|
|
Self {
|
|
max_connections,
|
|
soft_connections,
|
|
counts: UserConnectionCounts::default(),
|
|
connections,
|
|
granted_grace: false,
|
|
grace_ts: 0,
|
|
sessions: Vec::new(),
|
|
streams: Vec::new(),
|
|
stream_kinds: HashMap::new(),
|
|
stream_normal_priorities: HashMap::new(),
|
|
ts: current_time_secs(),
|
|
}
|
|
}
|
|
|
|
fn add_session(&mut self, session: UserSession) {
|
|
self.gc();
|
|
self.sessions.push(session);
|
|
}
|
|
fn gc(&mut self) {
|
|
if self.sessions.len() > USER_SESSION_LIMIT {
|
|
self.sessions.sort_by_key(|e| std::cmp::Reverse(e.ts));
|
|
self.sessions.truncate(USER_SESSION_LIMIT);
|
|
}
|
|
}
|
|
|
|
fn has_session_addr(&self, addr: &SocketAddr) -> bool {
|
|
self.sessions
|
|
.iter()
|
|
.any(|session| session.addr == *addr || session.active_addrs.contains(addr))
|
|
}
|
|
|
|
fn release_addr_from_sessions(&mut self, addr: &SocketAddr) -> HashMap<String, Option<SocketAddr>> {
|
|
let mut migrated_addrs = HashMap::new();
|
|
for session in &mut self.sessions {
|
|
if session.addr == *addr || session.active_addrs.contains(addr) {
|
|
migrated_addrs.insert(session.token.clone(), release_session_addr(session, addr));
|
|
}
|
|
}
|
|
migrated_addrs
|
|
}
|
|
|
|
fn increment_kind(&mut self, kind: ConnectionKind) {
|
|
self.connections = self.connections.saturating_add(1);
|
|
match kind {
|
|
ConnectionKind::Normal => {
|
|
self.counts.normal = self.counts.normal.saturating_add(1);
|
|
}
|
|
ConnectionKind::Soft => {
|
|
self.counts.soft = self.counts.soft.saturating_add(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn decrement_kind(&mut self, kind: ConnectionKind) {
|
|
self.connections = self.connections.saturating_sub(1);
|
|
match kind {
|
|
ConnectionKind::Normal => {
|
|
self.counts.normal = self.counts.normal.saturating_sub(1);
|
|
}
|
|
ConnectionKind::Soft => {
|
|
self.counts.soft = self.counts.soft.saturating_sub(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn remove_streams_for_session_and_release_counted(
|
|
&mut self,
|
|
session_token: &str,
|
|
counted_kind: Option<ConnectionKind>,
|
|
) -> (u32, bool) {
|
|
let mut removed_count = 0;
|
|
let mut connection_changed = false;
|
|
let mut released_stream_kind = false;
|
|
let mut stream_idx = 0;
|
|
while stream_idx < self.streams.len() {
|
|
if self.streams[stream_idx].session_token.as_deref() != Some(session_token) {
|
|
stream_idx += 1;
|
|
continue;
|
|
}
|
|
|
|
let uid = self.streams[stream_idx].uid;
|
|
if let Some(kind) = self.stream_kinds.remove(&uid) {
|
|
self.decrement_kind(kind);
|
|
released_stream_kind = true;
|
|
connection_changed = true;
|
|
}
|
|
self.stream_normal_priorities.remove(&uid);
|
|
self.streams.swap_remove(stream_idx);
|
|
removed_count += 1;
|
|
}
|
|
|
|
if let Some(kind) = counted_kind.filter(|_| !released_stream_kind) {
|
|
self.decrement_kind(kind);
|
|
connection_changed = true;
|
|
}
|
|
|
|
(removed_count, connection_changed)
|
|
}
|
|
|
|
fn try_promote_soft_stream(&mut self) -> Option<PromotionAction> {
|
|
if self.counts.normal >= self.max_connections || (u32::from(self.counts.soft)) <= u32::from(self.soft_connections) {
|
|
return None;
|
|
}
|
|
|
|
let candidate_uid = self
|
|
.streams
|
|
.iter()
|
|
.filter(|stream| !stream.preserved)
|
|
.filter_map(|stream| {
|
|
let kind = self.stream_kinds.get(&stream.uid).copied()?;
|
|
if kind != ConnectionKind::Soft {
|
|
return None;
|
|
}
|
|
let normal_priority = self.stream_normal_priorities.get(&stream.uid).copied().unwrap_or_default();
|
|
Some((normal_priority, stream.ts, stream.uid, stream.addr))
|
|
})
|
|
.min_by_key(|(normal_priority, ts, uid, _)| (*normal_priority, *ts, *uid));
|
|
|
|
let (new_priority, _ts, uid, addr) = candidate_uid?;
|
|
|
|
self.counts.normal = self.counts.normal.saturating_add(1);
|
|
if self.counts.soft > 0 {
|
|
self.counts.soft -= 1;
|
|
}
|
|
self.stream_kinds.insert(uid, ConnectionKind::Normal);
|
|
|
|
Some(PromotionAction {
|
|
addr,
|
|
uid,
|
|
new_priority,
|
|
})
|
|
}
|
|
|
|
fn try_promote_soft_session_reservation(&mut self) -> bool {
|
|
if self.counts.normal >= self.max_connections || (u32::from(self.counts.soft)) <= u32::from(self.soft_connections) {
|
|
return false;
|
|
}
|
|
|
|
let active_tokens = self
|
|
.streams
|
|
.iter()
|
|
.filter_map(|stream| stream.session_token.as_deref())
|
|
.collect::<HashSet<_>>();
|
|
|
|
let candidate_index = self.sessions.iter().position(|session| {
|
|
session.lifecycle.is_counted()
|
|
&& session.connection_kind == Some(ConnectionKind::Soft)
|
|
&& !active_tokens.contains(session.token.as_str())
|
|
});
|
|
|
|
let Some(candidate_index) = candidate_index else {
|
|
return false;
|
|
};
|
|
|
|
self.counts.normal = self.counts.normal.saturating_add(1);
|
|
if self.counts.soft > 0 {
|
|
self.counts.soft -= 1;
|
|
}
|
|
self.sessions[candidate_index].connection_kind = Some(ConnectionKind::Normal);
|
|
true
|
|
}
|
|
|
|
fn effective_counts_for_admission(&self, exclude_session_token: Option<&str>) -> UserConnectionCounts {
|
|
let mut counts = self.counts;
|
|
let counted_tokens = self
|
|
.sessions
|
|
.iter()
|
|
.filter(|session| session.lifecycle.is_counted())
|
|
.map(|session| session.token.as_str())
|
|
.collect::<HashSet<_>>();
|
|
let mut reserved_tokens = HashSet::new();
|
|
|
|
for stream in self.streams.iter().filter(|stream| stream.preserved) {
|
|
// Orphan preserved stream: no session token means no session to evict.
|
|
// Do not count it — it has no bearing on admission decisions.
|
|
let Some(session_token) = stream.session_token.as_deref() else {
|
|
continue;
|
|
};
|
|
if exclude_session_token.is_some_and(|token| token == session_token)
|
|
|| counted_tokens.contains(session_token)
|
|
|| !reserved_tokens.insert(session_token)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let kind = self
|
|
.sessions
|
|
.iter()
|
|
.find(|session| session.token == session_token)
|
|
.and_then(|session| session.connection_kind)
|
|
.unwrap_or(ConnectionKind::Normal);
|
|
match kind {
|
|
ConnectionKind::Normal => counts.normal = counts.normal.saturating_add(1),
|
|
ConnectionKind::Soft => counts.soft = counts.soft.saturating_add(1),
|
|
}
|
|
}
|
|
|
|
counts
|
|
}
|
|
}
|
|
|
|
fn create_socket_reentry_guard_key(username: &str, client_ip: &str, virtual_id: VirtualId) -> String {
|
|
shared::concat_string!(username, "|", client_ip, "|", &virtual_id.to_string())
|
|
}
|
|
|
|
fn is_stable_session_stream(stream: &StreamInfo) -> bool {
|
|
stream.channel.item_type == PlaylistItemType::Catchup
|
|
|| stream.channel.item_type.is_live_adaptive()
|
|
|| matches!(
|
|
extract_extension_from_url(stream.channel.url.as_ref()),
|
|
Some(ext) if ext == HLS_EXT || ext == DASH_EXT
|
|
)
|
|
}
|
|
|
|
fn uses_session_reentry_guard(stream: &StreamInfo) -> bool {
|
|
stream.channel.item_type.requires_provider_affinity()
|
|
|| matches!(
|
|
extract_extension_from_url(stream.channel.url.as_ref()),
|
|
Some(ext) if ext == HLS_EXT || ext == DASH_EXT
|
|
)
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct RecentWinnerProtection {
|
|
protected_addr: SocketAddr,
|
|
expires_at: u64,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct UserConnections {
|
|
kicked: HashMap<String, (u64, VirtualId)>,
|
|
recently_evicted_sessions: HashMap<String, RecentWinnerProtection>,
|
|
recent_socket_reentry_guards: HashMap<String, RecentWinnerProtection>,
|
|
by_key: HashMap<String, UserConnectionData>,
|
|
key_by_addr: HashMap<SocketAddr, SocketRegistration>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct SocketRegistration {
|
|
username: String,
|
|
ts: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
|
|
struct AdaptiveExpiryEntry {
|
|
expires_at: u64,
|
|
username: String,
|
|
session_token: String,
|
|
uid: u32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
|
|
struct AdaptiveExpiryKey {
|
|
username: String,
|
|
session_token: String,
|
|
uid: u32,
|
|
}
|
|
|
|
pub struct ReleasedConnection {
|
|
pub addr_removed: bool,
|
|
pub removed_streams: Vec<StreamInfo>,
|
|
pub disconnected_users: Vec<String>,
|
|
}
|
|
|
|
pub struct ActiveUserConnectionParams<'a> {
|
|
pub uid: u32,
|
|
pub meter_uid: u32,
|
|
pub username: &'a str,
|
|
pub max_connections: u32,
|
|
pub soft_connections: u16,
|
|
pub connection_kind: ConnectionKind,
|
|
pub priority: i8,
|
|
pub soft_priority: i8,
|
|
pub fingerprint: &'a Fingerprint,
|
|
pub provider: Arc<str>,
|
|
pub stream_channel: &'a StreamChannel,
|
|
pub user_agent: Cow<'a, str>,
|
|
pub session_token: Option<&'a str>,
|
|
}
|
|
|
|
pub struct CreateUserSessionParams<'a> {
|
|
pub user: &'a ProxyUserCredentials,
|
|
pub session_token: &'a str,
|
|
pub virtual_id: u32,
|
|
pub provider: &'a str,
|
|
pub stream_url: &'a str,
|
|
pub addr: &'a SocketAddr,
|
|
pub connection_permission: UserConnectionPermission,
|
|
pub connection_kind: Option<ConnectionKind>,
|
|
pub socket_bound: bool,
|
|
}
|
|
|
|
fn remember_session_addr(session: &mut UserSession, addr: SocketAddr) {
|
|
if session.socket_bound {
|
|
session.active_addrs.clear();
|
|
} else if let Some(position) = session.active_addrs.iter().position(|active_addr| *active_addr == addr) {
|
|
session.active_addrs.remove(position);
|
|
}
|
|
session.active_addrs.push(addr);
|
|
session.addr = addr;
|
|
}
|
|
|
|
fn release_session_addr(session: &mut UserSession, addr: &SocketAddr) -> Option<SocketAddr> {
|
|
if let Some(position) = session.active_addrs.iter().position(|active_addr| active_addr == addr) {
|
|
session.active_addrs.remove(position);
|
|
} else if session.addr != *addr {
|
|
return None;
|
|
}
|
|
|
|
if session.addr == *addr {
|
|
if let Some(next_addr) = session.active_addrs.last().copied() {
|
|
session.addr = next_addr;
|
|
return Some(next_addr);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn clear_session_addr(session: &mut UserSession, addr: &SocketAddr) -> bool {
|
|
let mut changed = false;
|
|
if let Some(position) = session.active_addrs.iter().position(|active_addr| active_addr == addr) {
|
|
session.active_addrs.remove(position);
|
|
changed = true;
|
|
}
|
|
|
|
if session.addr == *addr {
|
|
if let Some(next_addr) = session.active_addrs.last().copied() {
|
|
session.addr = next_addr;
|
|
} else {
|
|
session.addr = SocketAddr::from(([0, 0, 0, 0], 0));
|
|
}
|
|
changed = true;
|
|
}
|
|
|
|
changed
|
|
}
|
|
|
|
impl SocketRegistration {
|
|
fn anonymous() -> Self {
|
|
Self {
|
|
username: String::new(),
|
|
ts: current_time_secs(),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct UserSessionParams<'a> {
|
|
session_token: &'a str,
|
|
virtual_id: u32,
|
|
provider: &'a str,
|
|
stream_url: &'a str,
|
|
addr: &'a SocketAddr,
|
|
connection_permission: UserConnectionPermission,
|
|
connection_kind: Option<ConnectionKind>,
|
|
socket_bound: bool,
|
|
}
|
|
|
|
pub struct ActiveUserManager {
|
|
grace_period_millis: AtomicU64,
|
|
grace_period_timeout_secs: AtomicU64,
|
|
adaptive_session_ttl_secs: AtomicU64,
|
|
log_active_user: AtomicBool,
|
|
gc_ts: Option<AtomicU64>,
|
|
connections: RwLock<UserConnections>,
|
|
adaptive_expiry_queue: Arc<Mutex<BinaryHeap<Reverse<AdaptiveExpiryEntry>>>>,
|
|
adaptive_expiry_index: Arc<Mutex<HashMap<AdaptiveExpiryKey, u64>>>,
|
|
adaptive_expiry_notify: Arc<Notify>,
|
|
adaptive_expiry_cancel: CancellationToken,
|
|
adaptive_expiry_worker_started: AtomicBool,
|
|
event_manager: Arc<EventManager>,
|
|
geo_ip: Arc<ArcSwapOption<GeoIp>>,
|
|
last_logged_user_count: AtomicUsize,
|
|
last_logged_user_connection_count: AtomicUsize,
|
|
cleanup_tx: tokio::sync::OnceCell<mpsc::Sender<CleanupEvent>>,
|
|
provider_manager: tokio::sync::OnceCell<Arc<ActiveProviderManager>>,
|
|
transition_gates: Mutex<HashMap<String, Arc<Mutex<()>>>>,
|
|
pub(crate) dropped_cleanup_events: AtomicU64,
|
|
divergence_cache: Mutex<LruCache<String, DivergenceEntry>>,
|
|
divergence_cooldown_secs: u64,
|
|
}
|
|
|
|
struct DivergenceEntry {
|
|
last_logged: Instant,
|
|
count_since_last_log: u32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
|
|
enum DivergenceKind {
|
|
CountedSessionWithoutStream,
|
|
StreamWithoutCountedSession,
|
|
ConnectionCountMismatch { legacy: u32, counted: u32 },
|
|
}
|
|
|
|
fn divergence_key(username: &str, kind: &DivergenceKind) -> String {
|
|
match kind {
|
|
DivergenceKind::CountedSessionWithoutStream => format!("{username}:CountedSessionWithoutStream"),
|
|
DivergenceKind::StreamWithoutCountedSession => format!("{username}:StreamWithoutCountedSession"),
|
|
DivergenceKind::ConnectionCountMismatch { legacy, counted } => {
|
|
format!("{username}:ConnectionCountMismatch:{legacy}+{counted}")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DivergenceSnapshot {
|
|
username: String,
|
|
connections: u32,
|
|
counted_sessions: usize,
|
|
streams_count: usize,
|
|
kinds: Vec<DivergenceKind>,
|
|
}
|
|
|
|
impl ActiveUserManager {
|
|
pub fn shutdown(&self) {
|
|
self.adaptive_expiry_cancel.cancel();
|
|
}
|
|
|
|
pub fn start_adaptive_expiry_worker(self: &Arc<Self>) {
|
|
if self
|
|
.adaptive_expiry_worker_started
|
|
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
|
|
let manager = Arc::clone(self);
|
|
tokio::spawn(async move {
|
|
manager.run_adaptive_expiry_worker().await;
|
|
});
|
|
}
|
|
|
|
fn lookup_country(&self, client_ip: &str) -> Option<String> {
|
|
let geoip = self.geo_ip.load();
|
|
(*geoip)
|
|
.as_ref()
|
|
.and_then(|geoip_db| geoip_db.lookup(&strip_port(client_ip)))
|
|
}
|
|
|
|
fn custom_stream_technical_info() -> StreamTechnicalInfo {
|
|
StreamTechnicalInfo {
|
|
container: String::from("mpegts"),
|
|
resolution: String::new(),
|
|
fps: String::from("30"),
|
|
video_codec: String::from("H.264"),
|
|
audio_codec: String::from("AAC"),
|
|
audio_channels: String::from("Stereo"),
|
|
}
|
|
}
|
|
|
|
pub fn new(config: &Config, geoip: &Arc<ArcSwapOption<GeoIp>>, event_manager: &Arc<EventManager>) -> Self {
|
|
let log_active_user: bool = config.log.as_ref().is_some_and(|l| l.log_active_user);
|
|
let (grace_period_millis, grace_period_timeout_secs) = get_grace_options(config);
|
|
|
|
Self {
|
|
grace_period_millis: AtomicU64::new(grace_period_millis),
|
|
grace_period_timeout_secs: AtomicU64::new(grace_period_timeout_secs),
|
|
adaptive_session_ttl_secs: AtomicU64::new(get_adaptive_session_ttl_secs(config)),
|
|
log_active_user: AtomicBool::new(log_active_user),
|
|
connections: RwLock::new(UserConnections::default()),
|
|
adaptive_expiry_queue: Arc::new(Mutex::new(BinaryHeap::new())),
|
|
adaptive_expiry_index: Arc::new(Mutex::new(HashMap::new())),
|
|
adaptive_expiry_notify: Arc::new(Notify::new()),
|
|
adaptive_expiry_cancel: CancellationToken::new(),
|
|
adaptive_expiry_worker_started: AtomicBool::new(false),
|
|
gc_ts: Some(AtomicU64::new(current_time_secs())),
|
|
geo_ip: Arc::clone(geoip),
|
|
event_manager: Arc::clone(event_manager),
|
|
last_logged_user_count: AtomicUsize::new(0),
|
|
last_logged_user_connection_count: AtomicUsize::new(0),
|
|
cleanup_tx: tokio::sync::OnceCell::new(),
|
|
provider_manager: tokio::sync::OnceCell::new(),
|
|
transition_gates: Mutex::new(HashMap::new()),
|
|
dropped_cleanup_events: AtomicU64::new(0),
|
|
divergence_cache: Mutex::new(LruCache::new(NonZeroUsize::new(256).unwrap())),
|
|
divergence_cooldown_secs: 300,
|
|
}
|
|
}
|
|
|
|
fn transition_gate_key(username: &str, token: &str) -> String {
|
|
let mut key = String::with_capacity(username.len() + token.len() + 1);
|
|
key.push_str(username);
|
|
key.push('\0');
|
|
key.push_str(token);
|
|
key
|
|
}
|
|
|
|
fn admission_gate_key(username: &str) -> String {
|
|
let mut key = String::with_capacity(username.len() + 11);
|
|
key.push_str("admission");
|
|
key.push('\0');
|
|
key.push_str(username);
|
|
key
|
|
}
|
|
|
|
fn cleanup_idle_transition_gates(transition_gates: &mut HashMap<String, Arc<Mutex<()>>>) {
|
|
transition_gates.retain(|_, gate| Arc::strong_count(gate) > 1);
|
|
}
|
|
|
|
pub(crate) async fn acquire_playback_transition(&self, username: &str, token: &str) -> tokio::sync::OwnedMutexGuard<()> {
|
|
let key = Self::transition_gate_key(username, token);
|
|
let gate = {
|
|
let mut transition_gates = self.transition_gates.lock().await;
|
|
Self::cleanup_idle_transition_gates(&mut transition_gates);
|
|
Arc::clone(
|
|
transition_gates
|
|
.entry(key)
|
|
.or_insert_with(|| Arc::new(Mutex::new(()))),
|
|
)
|
|
};
|
|
gate.lock_owned().await
|
|
}
|
|
|
|
pub(crate) async fn acquire_user_admission(&self, username: &str) -> tokio::sync::OwnedMutexGuard<()> {
|
|
let key = Self::admission_gate_key(username);
|
|
let gate = {
|
|
let mut transition_gates = self.transition_gates.lock().await;
|
|
Self::cleanup_idle_transition_gates(&mut transition_gates);
|
|
Arc::clone(
|
|
transition_gates
|
|
.entry(key)
|
|
.or_insert_with(|| Arc::new(Mutex::new(()))),
|
|
)
|
|
};
|
|
gate.lock_owned().await
|
|
}
|
|
|
|
fn should_reuse_stream_for_session(existing_stream: &StreamInfo, incoming_channel: &StreamChannel) -> bool {
|
|
existing_stream.channel.item_type.requires_provider_affinity() || incoming_channel.item_type.requires_provider_affinity()
|
|
}
|
|
|
|
pub(crate) fn set_cleanup_sender(&self, tx: mpsc::Sender<CleanupEvent>) {
|
|
let _ = self.cleanup_tx.set(tx);
|
|
}
|
|
|
|
pub(crate) fn set_provider_manager(&self, provider_manager: Arc<ActiveProviderManager>) {
|
|
let _ = self.provider_manager.set(provider_manager);
|
|
}
|
|
|
|
/// Collect a snapshot of all currently active streams for shutdown history recording.
|
|
pub(crate) async fn get_all_active_streams(&self) -> Vec<shared::model::StreamInfo> {
|
|
let connections = self.connections.read().await;
|
|
connections.by_key.values()
|
|
.flat_map(|data| data.streams.iter().filter(|stream| !stream.preserved).cloned())
|
|
.collect()
|
|
}
|
|
|
|
async fn log_active_user(&self) {
|
|
let is_log_user_enabled = self.is_log_user_enabled();
|
|
let (user_count, user_connection_count) = { self.active_users_and_connections().await };
|
|
self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Connections(
|
|
user_count,
|
|
user_connection_count,
|
|
)));
|
|
if !is_log_user_enabled {
|
|
return;
|
|
}
|
|
let last_user_count = self.last_logged_user_count.load(Ordering::Relaxed);
|
|
let last_connection_count = self.last_logged_user_connection_count.load(Ordering::Relaxed);
|
|
if last_user_count != user_count || last_connection_count != user_connection_count {
|
|
self.last_logged_user_count.store(user_count, Ordering::Relaxed);
|
|
self.last_logged_user_connection_count.store(user_connection_count, Ordering::Relaxed);
|
|
info!("Active Users: {user_count}, Active User Connections: {user_connection_count}");
|
|
}
|
|
}
|
|
|
|
async fn emit_promotion_update(&self, username: &str, action: PromotionAction) {
|
|
if let Some(provider_manager) = self.provider_manager.get() {
|
|
provider_manager
|
|
.reclassify_connection(&action.addr, ConnectionKind::Normal, action.new_priority)
|
|
.await;
|
|
}
|
|
|
|
let maybe_stream = {
|
|
let user_connections = self.connections.read().await;
|
|
user_connections
|
|
.by_key
|
|
.get(username)
|
|
.and_then(|connection_data| connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned())
|
|
};
|
|
if let Some(stream_info) = maybe_stream {
|
|
self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info)));
|
|
}
|
|
}
|
|
|
|
/// Releases an active stream for the given socket address without removing the
|
|
/// socket registration (`key_by_addr`). This is used when a stream ends while
|
|
/// the underlying HTTP connection may still remain open.
|
|
#[allow(clippy::too_many_lines)]
|
|
pub async fn release_stream(&self, addr: &SocketAddr) -> Option<StreamInfo> {
|
|
self.release_stream_inner(addr, None).await
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
pub async fn release_stream_by_uid(&self, addr: &SocketAddr, stream_uid: u32) -> Option<StreamInfo> {
|
|
self.release_stream_inner(addr, Some(stream_uid)).await
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn release_stream_inner(&self, addr: &SocketAddr, stream_uid: Option<u32>) -> Option<StreamInfo> {
|
|
let (removed_stream, username, expiry_entry, preserved_update, connection_changed, promotion, divergence_snapshot) = {
|
|
let mut user_connections = self.connections.write().await;
|
|
|
|
let username = match stream_uid {
|
|
Some(uid) => user_connections.by_key.iter().find_map(|(username, connection_data)| {
|
|
connection_data
|
|
.streams
|
|
.iter()
|
|
.any(|stream| !stream.preserved && stream.uid == uid && stream.addr == *addr)
|
|
.then(|| username.clone())
|
|
}),
|
|
None => user_connections
|
|
.key_by_addr
|
|
.get(addr)
|
|
.filter(|reg| !reg.username.is_empty())
|
|
.map(|reg| reg.username.clone())
|
|
.or_else(|| {
|
|
user_connections.by_key.iter().find_map(|(username, connection_data)| {
|
|
connection_data
|
|
.streams
|
|
.iter()
|
|
.any(|stream| !stream.preserved && stream.addr == *addr)
|
|
.then(|| username.clone())
|
|
})
|
|
}),
|
|
}?;
|
|
|
|
let mut removed_stream = None;
|
|
let mut expiry_entry = None;
|
|
let mut preserved_update = None;
|
|
let mut connection_changed = false;
|
|
let mut promotion = None;
|
|
if let Some(connection_data) = user_connections.by_key.get_mut(&username) {
|
|
let migrated_session_addrs = connection_data.release_addr_from_sessions(addr);
|
|
if let Some(stream_idx) = connection_data.streams.iter().position(|stream| {
|
|
!stream.preserved
|
|
&& stream_uid.map_or(stream.addr == *addr, |uid| {
|
|
stream.uid == uid && stream.addr == *addr
|
|
})
|
|
})
|
|
{
|
|
let migrated_addr = connection_data.streams[stream_idx]
|
|
.session_token
|
|
.as_deref()
|
|
.and_then(|token| migrated_session_addrs.get(token))
|
|
.copied()
|
|
.flatten();
|
|
if let Some(next_addr) = migrated_addr {
|
|
connection_data.streams[stream_idx].addr = next_addr;
|
|
connection_data.streams[stream_idx].ts = current_time_secs();
|
|
} else if Self::should_preserve_session_stream(&connection_data.streams[stream_idx]) {
|
|
let preserved_session_token =
|
|
connection_data.streams[stream_idx].session_token.clone();
|
|
if let Some(entry) = self.build_preserved_stream_expiry(
|
|
&username,
|
|
&connection_data.streams[stream_idx],
|
|
&connection_data.sessions,
|
|
) {
|
|
if let Some(kind) = connection_data.stream_kinds.remove(&connection_data.streams[stream_idx].uid) {
|
|
connection_data.decrement_kind(kind);
|
|
connection_changed = true;
|
|
}
|
|
connection_data
|
|
.stream_normal_priorities
|
|
.remove(&connection_data.streams[stream_idx].uid);
|
|
if let Some(session_token) = preserved_session_token.as_deref() {
|
|
Self::clear_session_counted(connection_data, session_token);
|
|
}
|
|
connection_data.streams[stream_idx].preserved = true;
|
|
preserved_update = Some(connection_data.streams[stream_idx].clone());
|
|
expiry_entry = Some(entry);
|
|
} else {
|
|
removed_stream = Some(connection_data.streams.swap_remove(stream_idx));
|
|
}
|
|
} else {
|
|
removed_stream = Some(connection_data.streams.swap_remove(stream_idx));
|
|
}
|
|
if let Some(removed_stream) = removed_stream.as_ref() {
|
|
if let Some(kind) = connection_data.stream_kinds.remove(&removed_stream.uid) {
|
|
connection_data.decrement_kind(kind);
|
|
}
|
|
connection_data.stream_normal_priorities.remove(&removed_stream.uid);
|
|
connection_changed = true;
|
|
}
|
|
if connection_data.connections < connection_data.max_connections {
|
|
connection_data.granted_grace = false;
|
|
connection_data.grace_ts = 0;
|
|
}
|
|
if removed_stream.is_some() {
|
|
if let Some(action) = connection_data.try_promote_soft_stream() {
|
|
let promoted_stream =
|
|
connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned();
|
|
if let Some(stream) = promoted_stream.as_ref() {
|
|
Self::promote_session_for_stream(connection_data, stream);
|
|
}
|
|
promotion = Some(action);
|
|
}
|
|
if let Some(session_token) = removed_stream
|
|
.as_ref()
|
|
.and_then(|stream| stream.session_token.as_deref())
|
|
{
|
|
Self::clear_session_counted_without_stream(connection_data, session_token);
|
|
}
|
|
while connection_data.try_promote_soft_session_reservation() {}
|
|
}
|
|
}
|
|
let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, &username);
|
|
(removed_stream, username, expiry_entry, preserved_update, connection_changed, promotion, Some(divergence_snapshot))
|
|
} else {
|
|
(None, username, None, None, false, None, None)
|
|
}
|
|
};
|
|
|
|
if let Some(snapshot) = divergence_snapshot {
|
|
self.log_divergence_snapshot(snapshot).await;
|
|
}
|
|
|
|
if let Some(entry) = expiry_entry {
|
|
self.enqueue_adaptive_expiry(entry).await;
|
|
}
|
|
|
|
if let Some(stream_info) = preserved_update {
|
|
self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info)));
|
|
}
|
|
|
|
if connection_changed {
|
|
if !username.is_empty() {
|
|
debug_if_enabled!(
|
|
"Released stream for user {username} at {}",
|
|
sanitize_sensitive_info(&addr.to_string())
|
|
);
|
|
}
|
|
self.log_active_user().await;
|
|
}
|
|
|
|
if let Some(action) = promotion {
|
|
self.emit_promotion_update(&username, action).await;
|
|
}
|
|
|
|
removed_stream
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn release_connection_inner(&self, addr: &SocketAddr, preserve_session_streams: bool) -> ReleasedConnection {
|
|
let (
|
|
addr_removed,
|
|
connection_count_changed,
|
|
disconnected_users,
|
|
removed_streams,
|
|
expiry_entries,
|
|
preserved_updates,
|
|
promotions,
|
|
) = {
|
|
let mut user_connections = self.connections.write().await;
|
|
|
|
let registration = user_connections.key_by_addr.remove(addr);
|
|
let had_registration = registration.is_some();
|
|
let mut disconnected_users = registration
|
|
.map(|registration| registration.username)
|
|
.filter(|username| !username.is_empty())
|
|
.into_iter()
|
|
.collect::<Vec<_>>();
|
|
disconnected_users.extend(
|
|
user_connections
|
|
.by_key
|
|
.iter()
|
|
.filter(|(_, connection_data)| {
|
|
connection_data.has_session_addr(addr)
|
|
|| connection_data.streams.iter().any(|stream| stream.addr == *addr)
|
|
})
|
|
.map(|(username, _)| username.clone()),
|
|
);
|
|
disconnected_users.sort_unstable();
|
|
disconnected_users.dedup();
|
|
|
|
let mut removed_streams = Vec::new();
|
|
let mut expiry_entries = Vec::new();
|
|
let mut preserved_updates = Vec::new();
|
|
let mut promotions = Vec::new();
|
|
let mut connection_count_changed = false;
|
|
for username in &disconnected_users {
|
|
if let Some(connection_data) = user_connections.by_key.get_mut(username) {
|
|
let previous_connection_count = connection_data.connections;
|
|
let migrated_session_addrs = connection_data.release_addr_from_sessions(addr);
|
|
let mut remaining_streams = Vec::with_capacity(connection_data.streams.len());
|
|
let mut released_kinds = Vec::new();
|
|
let mut removed_session_tokens = HashSet::new();
|
|
let mut preserved_session_tokens = Vec::new();
|
|
let now = current_time_secs();
|
|
for mut stream_info in connection_data.streams.drain(..) {
|
|
if stream_info.addr == *addr {
|
|
let migrated_addr = stream_info
|
|
.session_token
|
|
.as_deref()
|
|
.and_then(|token| migrated_session_addrs.get(token))
|
|
.copied()
|
|
.flatten();
|
|
if let Some(next_addr) = migrated_addr {
|
|
stream_info.addr = next_addr;
|
|
stream_info.ts = now;
|
|
remaining_streams.push(stream_info);
|
|
} else if preserve_session_streams && Self::should_preserve_session_stream(&stream_info) {
|
|
if let Some(entry) =
|
|
self.build_preserved_stream_expiry(username, &stream_info, &connection_data.sessions)
|
|
{
|
|
if let Some(kind) = connection_data.stream_kinds.remove(&stream_info.uid) {
|
|
released_kinds.push(kind);
|
|
}
|
|
connection_data.stream_normal_priorities.remove(&stream_info.uid);
|
|
if let Some(token) = stream_info.session_token.as_ref() {
|
|
preserved_session_tokens.push(token.clone());
|
|
}
|
|
if !stream_info.preserved {
|
|
stream_info.preserved = true;
|
|
preserved_updates.push(stream_info.clone());
|
|
}
|
|
expiry_entries.push(entry);
|
|
remaining_streams.push(stream_info);
|
|
} else {
|
|
if let Some(kind) = connection_data.stream_kinds.remove(&stream_info.uid) {
|
|
released_kinds.push(kind);
|
|
}
|
|
connection_data.stream_normal_priorities.remove(&stream_info.uid);
|
|
if let Some(token) = stream_info.session_token.as_ref() {
|
|
removed_session_tokens.insert(token.clone());
|
|
}
|
|
removed_streams.push(stream_info);
|
|
}
|
|
} else {
|
|
if let Some(kind) = connection_data.stream_kinds.remove(&stream_info.uid) {
|
|
released_kinds.push(kind);
|
|
}
|
|
connection_data.stream_normal_priorities.remove(&stream_info.uid);
|
|
if let Some(token) = stream_info.session_token.as_ref() {
|
|
removed_session_tokens.insert(token.clone());
|
|
}
|
|
removed_streams.push(stream_info);
|
|
}
|
|
} else {
|
|
remaining_streams.push(stream_info);
|
|
}
|
|
}
|
|
connection_data.streams = remaining_streams;
|
|
if !preserve_session_streams && !removed_session_tokens.is_empty() {
|
|
connection_data
|
|
.sessions
|
|
.retain(|session| !removed_session_tokens.contains(&session.token));
|
|
}
|
|
for kind in released_kinds {
|
|
connection_data.decrement_kind(kind);
|
|
}
|
|
while let Some(action) = connection_data.try_promote_soft_stream() {
|
|
let promoted_stream =
|
|
connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned();
|
|
if let Some(stream) = promoted_stream.as_ref() {
|
|
Self::promote_session_for_stream(connection_data, stream);
|
|
}
|
|
promotions.push((username.clone(), action));
|
|
}
|
|
for session_token in &removed_session_tokens {
|
|
Self::clear_session_counted_without_stream(connection_data, session_token);
|
|
}
|
|
for session_token in &preserved_session_tokens {
|
|
Self::clear_session_counted(connection_data, session_token);
|
|
}
|
|
while connection_data.try_promote_soft_session_reservation() {}
|
|
|
|
if connection_data.connections < connection_data.max_connections {
|
|
connection_data.granted_grace = false;
|
|
connection_data.grace_ts = 0;
|
|
}
|
|
connection_count_changed |= connection_data.connections != previous_connection_count;
|
|
}
|
|
}
|
|
let state_changed = had_registration || !disconnected_users.is_empty();
|
|
(
|
|
state_changed,
|
|
connection_count_changed,
|
|
disconnected_users,
|
|
removed_streams,
|
|
expiry_entries,
|
|
preserved_updates,
|
|
promotions,
|
|
)
|
|
};
|
|
|
|
for entry in expiry_entries {
|
|
self.enqueue_adaptive_expiry(entry).await;
|
|
}
|
|
|
|
for stream_info in preserved_updates {
|
|
self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info)));
|
|
}
|
|
|
|
for username in &disconnected_users {
|
|
if !username.is_empty() {
|
|
debug_if_enabled!(
|
|
"Released connection for user {username} at {}",
|
|
sanitize_sensitive_info(&addr.to_string())
|
|
);
|
|
}
|
|
}
|
|
if connection_count_changed {
|
|
self.log_active_user().await;
|
|
}
|
|
if addr_removed {
|
|
for (username, action) in promotions {
|
|
self.emit_promotion_update(&username, action).await;
|
|
}
|
|
}
|
|
|
|
ReleasedConnection { addr_removed, removed_streams, disconnected_users }
|
|
}
|
|
|
|
pub async fn release_connection(&self, addr: &SocketAddr) -> ReleasedConnection {
|
|
let released = self.release_connection_inner(addr, true).await;
|
|
// divergence check after connection release
|
|
if released.addr_removed {
|
|
for username in &released.disconnected_users {
|
|
self.check_and_log_divergence_for_user(username).await;
|
|
}
|
|
}
|
|
released
|
|
}
|
|
|
|
pub async fn release_connection_as_kicked(&self, addr: &SocketAddr) -> ReleasedConnection {
|
|
let released = self.release_connection_inner(addr, false).await;
|
|
// divergence check after connection release
|
|
if released.addr_removed {
|
|
for username in &released.disconnected_users {
|
|
self.check_and_log_divergence_for_user(username).await;
|
|
}
|
|
}
|
|
released
|
|
}
|
|
|
|
pub fn update_config(&self, config: &Config) {
|
|
let log_active_user = config.log.as_ref().is_some_and(|l| l.log_active_user);
|
|
let (grace_period_millis, grace_period_timeout_secs) = get_grace_options(config);
|
|
self.grace_period_millis.store(grace_period_millis, Ordering::Relaxed);
|
|
self.grace_period_timeout_secs.store(grace_period_timeout_secs, Ordering::Relaxed);
|
|
self.adaptive_session_ttl_secs
|
|
.store(get_adaptive_session_ttl_secs(config), Ordering::Relaxed);
|
|
self.log_active_user.store(log_active_user, Ordering::Relaxed);
|
|
}
|
|
|
|
pub async fn user_connections(&self, username: &str) -> u32 {
|
|
if let Some(connection_data) = self.connections.read().await.by_key.get(username) {
|
|
return connection_data.connections;
|
|
}
|
|
0
|
|
}
|
|
|
|
fn check_connection_admission_with_counts(
|
|
&self,
|
|
username: &str,
|
|
connection_data: &mut UserConnectionData,
|
|
counts: UserConnectionCounts,
|
|
) -> ConnectionAdmission {
|
|
let selected_kind = decide_connection_kind(counts, connection_data.max_connections, connection_data.soft_connections);
|
|
let effective_connections = counts.normal.saturating_add(u32::from(counts.soft));
|
|
|
|
if let Some(kind) = selected_kind {
|
|
// Reset grace only once the user is back below the hard limit.
|
|
if effective_connections < connection_data.max_connections {
|
|
connection_data.granted_grace = false;
|
|
connection_data.grace_ts = 0;
|
|
}
|
|
return ConnectionAdmission {
|
|
permission: UserConnectionPermission::Allowed,
|
|
kind: Some(kind),
|
|
};
|
|
}
|
|
|
|
let now = get_current_timestamp();
|
|
// Check if user already used a grace period
|
|
if connection_data.granted_grace {
|
|
if effective_connections >= connection_data.max_connections
|
|
&& now - connection_data.grace_ts <= self.grace_period_timeout_secs.load(Ordering::Relaxed)
|
|
{
|
|
// Grace timeout, still active, deny connection
|
|
debug!("User access denied, grace exhausted, too many connections: {username}");
|
|
return ConnectionAdmission {
|
|
permission: UserConnectionPermission::Exhausted,
|
|
kind: None,
|
|
};
|
|
}
|
|
// Grace timeout expired, reset grace counters
|
|
if effective_connections < connection_data.max_connections {
|
|
connection_data.granted_grace = false;
|
|
connection_data.grace_ts = 0;
|
|
}
|
|
}
|
|
|
|
debug!("User access denied, too many connections: {username}");
|
|
ConnectionAdmission {
|
|
permission: UserConnectionPermission::Exhausted,
|
|
kind: None,
|
|
}
|
|
}
|
|
|
|
fn check_connection_admission(&self, username: &str, connection_data: &mut UserConnectionData) -> ConnectionAdmission {
|
|
self.check_connection_admission_with_counts(
|
|
username,
|
|
connection_data,
|
|
connection_data.effective_counts_for_admission(None),
|
|
)
|
|
}
|
|
|
|
pub(crate) async fn connection_admission(
|
|
&self,
|
|
username: &str,
|
|
max_connections: u32,
|
|
soft_connections: u16,
|
|
) -> ConnectionAdmission {
|
|
if max_connections > 0 || soft_connections > 0 {
|
|
if let Some(connection_data) = self.connections.write().await.by_key.get_mut(username) {
|
|
connection_data.max_connections = max_connections;
|
|
connection_data.soft_connections = soft_connections;
|
|
return self.check_connection_admission(username, connection_data);
|
|
}
|
|
}
|
|
ConnectionAdmission {
|
|
permission: UserConnectionPermission::Allowed,
|
|
kind: Some(ConnectionKind::Normal),
|
|
}
|
|
}
|
|
|
|
pub async fn connection_permission(
|
|
&self,
|
|
username: &str,
|
|
max_connections: u32,
|
|
soft_connections: u16,
|
|
) -> UserConnectionPermission {
|
|
self.connection_admission(username, max_connections, soft_connections).await.permission
|
|
}
|
|
|
|
pub(crate) async fn connection_admission_for_session(
|
|
&self,
|
|
username: &str,
|
|
max_connections: u32,
|
|
soft_connections: u16,
|
|
session_token: &str,
|
|
) -> ConnectionAdmission {
|
|
if max_connections == 0 && soft_connections == 0 {
|
|
return ConnectionAdmission {
|
|
permission: UserConnectionPermission::Allowed,
|
|
kind: Some(ConnectionKind::Normal),
|
|
};
|
|
}
|
|
|
|
let mut connections = self.connections.write().await;
|
|
let Some(connection_data) = connections.by_key.get_mut(username) else {
|
|
return ConnectionAdmission {
|
|
permission: UserConnectionPermission::Allowed,
|
|
kind: Some(ConnectionKind::Normal),
|
|
};
|
|
};
|
|
connection_data.max_connections = max_connections;
|
|
connection_data.soft_connections = soft_connections;
|
|
|
|
let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == session_token) else {
|
|
return self.check_connection_admission(username, connection_data);
|
|
};
|
|
|
|
if connection_data.sessions[session_index].lifecycle.is_counted() {
|
|
return ConnectionAdmission {
|
|
permission: UserConnectionPermission::Allowed,
|
|
kind: connection_data.sessions[session_index]
|
|
.connection_kind
|
|
.or(Some(ConnectionKind::Normal)),
|
|
};
|
|
}
|
|
|
|
self.check_connection_admission_with_counts(
|
|
username,
|
|
connection_data,
|
|
connection_data.effective_counts_for_admission(Some(session_token)),
|
|
)
|
|
}
|
|
|
|
pub async fn connection_permission_for_session(
|
|
&self,
|
|
username: &str,
|
|
max_connections: u32,
|
|
soft_connections: u16,
|
|
session_token: &str,
|
|
) -> UserConnectionPermission {
|
|
self.connection_admission_for_session(username, max_connections, soft_connections, session_token)
|
|
.await
|
|
.permission
|
|
}
|
|
|
|
pub(crate) async fn refresh_session_connection_kind_for_origin_policy(
|
|
&self,
|
|
username: &str,
|
|
max_connections: u32,
|
|
soft_connections: u16,
|
|
session_token: &str,
|
|
) -> Option<ConnectionKind> {
|
|
if max_connections == 0 && soft_connections == 0 {
|
|
return Some(ConnectionKind::Normal);
|
|
}
|
|
|
|
let (connection_kind, promotions, divergence_snapshot) = {
|
|
let mut connections = self.connections.write().await;
|
|
let connection_data = connections.by_key.get_mut(username)?;
|
|
connection_data.max_connections = max_connections;
|
|
connection_data.soft_connections = soft_connections;
|
|
|
|
let session_index = connection_data.sessions.iter().position(|session| session.token == session_token)?;
|
|
|
|
let promotions = Self::promote_counted_soft_session_to_normal_if_available(connection_data, session_token);
|
|
let connection_kind = if connection_data.sessions[session_index].lifecycle.is_counted()
|
|
|| Self::session_has_stream(connection_data, session_token)
|
|
{
|
|
Some(
|
|
connection_data.sessions[session_index]
|
|
.connection_kind
|
|
.unwrap_or(ConnectionKind::Normal),
|
|
)
|
|
} else {
|
|
let admission = self.check_connection_admission_with_counts(
|
|
username,
|
|
connection_data,
|
|
connection_data.effective_counts_for_admission(Some(session_token)),
|
|
);
|
|
if admission.permission == UserConnectionPermission::Allowed {
|
|
if let Some(kind) = admission.kind {
|
|
Self::update_session_admission(
|
|
&mut connection_data.sessions[session_index],
|
|
admission.permission,
|
|
Some(kind),
|
|
);
|
|
}
|
|
admission.kind
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username);
|
|
|
|
(connection_kind, promotions, divergence_snapshot)
|
|
};
|
|
|
|
self.log_divergence_snapshot(divergence_snapshot).await;
|
|
for action in promotions {
|
|
self.emit_promotion_update(username, action).await;
|
|
}
|
|
|
|
connection_kind
|
|
}
|
|
|
|
pub(crate) async fn get_eviction_candidates(
|
|
&self,
|
|
username: &str,
|
|
_client_ip: &str,
|
|
) -> Vec<crate::api::model::EvictionCandidate> {
|
|
let connections = self.connections.read().await;
|
|
let Some(connection_data) = connections.by_key.get(username) else {
|
|
return Vec::new();
|
|
};
|
|
let mut addr_counts = HashMap::new();
|
|
for stream in &connection_data.streams {
|
|
// Preserved streams do not occupy a counted slot — exclude from addr counts.
|
|
// They are still valid eviction candidates (see filter below), but they don't
|
|
// consume connection capacity, so they don't contribute to the "singleton addr" logic.
|
|
let contributes_to_count = if stream.preserved {
|
|
false
|
|
} else if let Some(token) = stream.session_token.as_deref() {
|
|
connection_data.sessions.iter().any(|s| s.token == token && s.lifecycle.is_counted())
|
|
} else {
|
|
true // orphan streams without a session are counted
|
|
};
|
|
if contributes_to_count {
|
|
addr_counts
|
|
.entry(stream.addr)
|
|
.and_modify(|count: &mut u8| *count = count.saturating_add(1))
|
|
.or_insert(1_u8);
|
|
}
|
|
}
|
|
let candidates: Vec<_> = connection_data
|
|
.streams
|
|
.iter()
|
|
.filter(|stream| {
|
|
if let Some(token) = stream.session_token.as_deref() {
|
|
connection_data.sessions.iter().any(|s| s.token == token && s.lifecycle.is_counted()) || stream.preserved
|
|
} else {
|
|
true
|
|
}
|
|
})
|
|
.filter(|stream| {
|
|
let addr_count = addr_counts.get(&stream.addr).copied().unwrap_or(0);
|
|
if stream.preserved {
|
|
// Preserved streams are always valid eviction candidates — they hold no counted
|
|
// slot. addr_count is 0 for preserved-only addresses, 1+ for addresses with
|
|
// counted competition. Either way they can be evicted.
|
|
true
|
|
} else {
|
|
// Non-preserved streams: only on singleton counted addresses.
|
|
// addr_count 0 = no counted streams at this address (shouldn't happen since
|
|
// non-preserved streams aren't preserved, but addr_count would be >= 1).
|
|
// addr_count 1 = single counted stream at address — candidate.
|
|
// addr_count > 1 = multiple counted streams — not a singleton, not candidate.
|
|
addr_count == 1
|
|
}
|
|
})
|
|
.map(|s| crate::api::model::EvictionCandidate {
|
|
addr: s.addr,
|
|
client_ip: s.client_ip.clone(),
|
|
ts: s.ts,
|
|
})
|
|
.collect();
|
|
candidates
|
|
}
|
|
|
|
pub(crate) async fn grant_grace(&self, username: &str) -> bool {
|
|
if self.grace_period_millis.load(Ordering::Relaxed) == 0 {
|
|
debug!("Grace grant denied, grace_period_millis is zero for {username}");
|
|
return false;
|
|
}
|
|
let mut connections = self.connections.write().await;
|
|
if let Some(connection_data) = connections.by_key.get_mut(username) {
|
|
let now = get_current_timestamp();
|
|
if connection_data.connections < connection_data.max_connections {
|
|
debug!(
|
|
"Grace grant denied for {username}, user not at connection limit ({}/{})",
|
|
connection_data.connections,
|
|
connection_data.max_connections
|
|
);
|
|
return false;
|
|
}
|
|
if connection_data.granted_grace
|
|
&& connection_data.connections >= connection_data.max_connections
|
|
&& now - connection_data.grace_ts <= self.grace_period_timeout_secs.load(Ordering::Relaxed)
|
|
{
|
|
debug!("Grace grant denied, still within active grace timeout for {username}");
|
|
return false;
|
|
}
|
|
connection_data.granted_grace = true;
|
|
connection_data.grace_ts = now;
|
|
debug!("Granted a grace period for user access: {username}");
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
pub async fn active_users_and_connections(&self) -> (usize, usize) {
|
|
self.gc();
|
|
let user_connections = self.connections.read().await;
|
|
user_connections
|
|
.by_key
|
|
.values()
|
|
.filter_map(|c| {
|
|
let effective = c.connections as usize;
|
|
if effective > 0 { Some(effective) } else { None }
|
|
})
|
|
.fold((0usize, 0usize), |(user_count, conn_count), effective| (user_count + 1, conn_count + effective))
|
|
}
|
|
|
|
pub async fn update_stream_detail(
|
|
&self,
|
|
addr: &SocketAddr,
|
|
video_type: CustomVideoStreamType,
|
|
) -> Option<StreamInfo> {
|
|
let mut user_connections = self.connections.write().await;
|
|
let username = {
|
|
match user_connections.key_by_addr.get(addr) {
|
|
Some(registration) => registration.username.clone(),
|
|
None => return None,
|
|
}
|
|
};
|
|
if let Some(connection_data) = user_connections.by_key.get_mut(&username) {
|
|
for stream in &mut connection_data.streams {
|
|
if &stream.addr == addr {
|
|
// IMPORTANT: `resolve_disconnect_reason` in connection_manager.rs parses
|
|
// `channel.title` back via `CustomVideoStreamType::from_str` to determine QoS
|
|
// disconnect reasons. If these values change, update that function too.
|
|
stream.provider = "tuliprox".intern();
|
|
stream.channel.title = video_type.to_string().into();
|
|
stream.channel.group = "".intern();
|
|
stream.channel.technical = Some(Self::custom_stream_technical_info());
|
|
return Some(stream.clone());
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub async fn add_connection(&self, addr: &SocketAddr) {
|
|
self.gc();
|
|
let mut user_connections = self.connections.write().await;
|
|
user_connections
|
|
.key_by_addr
|
|
.entry(*addr)
|
|
.and_modify(|registration| registration.ts = current_time_secs())
|
|
.or_insert_with(SocketRegistration::anonymous);
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
pub async fn update_connection(&self, update: ActiveUserConnectionParams<'_>) -> Option<StreamInfo> {
|
|
let ActiveUserConnectionParams {
|
|
uid,
|
|
meter_uid,
|
|
username,
|
|
max_connections,
|
|
soft_connections,
|
|
connection_kind,
|
|
priority,
|
|
soft_priority: _,
|
|
fingerprint,
|
|
provider,
|
|
stream_channel,
|
|
user_agent,
|
|
session_token,
|
|
} = update;
|
|
let (stream_info, divergence_snapshot, connection_count_changed) = {
|
|
let mut user_connections = self.connections.write().await;
|
|
|
|
let now = current_time_secs();
|
|
if let Some(registration) = user_connections.key_by_addr.get_mut(&fingerprint.addr) {
|
|
registration.username = username.to_string();
|
|
registration.ts = now;
|
|
} else {
|
|
user_connections.key_by_addr.insert(
|
|
fingerprint.addr,
|
|
SocketRegistration {
|
|
username: username.to_string(),
|
|
ts: now,
|
|
},
|
|
);
|
|
}
|
|
|
|
let tracked_socket_count = user_connections.key_by_addr.len();
|
|
let connection_data = user_connections
|
|
.by_key
|
|
.entry(username.to_string())
|
|
.or_insert_with(|| UserConnectionData::new(0, max_connections, soft_connections));
|
|
connection_data.max_connections = max_connections;
|
|
connection_data.soft_connections = soft_connections;
|
|
let previous_connection_count = connection_data.connections;
|
|
|
|
if let Some(token) = session_token {
|
|
if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) {
|
|
session.ts = now;
|
|
remember_session_addr(session, fingerprint.addr);
|
|
Self::bump_session_transition_version(session);
|
|
}
|
|
}
|
|
|
|
let user_agent_string = user_agent.to_string();
|
|
let reserved_session_kind = session_token.and_then(|token| {
|
|
connection_data
|
|
.sessions
|
|
.iter()
|
|
.find(|session| session.token == token && session.lifecycle.is_counted())
|
|
.map(|session| session.connection_kind.unwrap_or(connection_kind))
|
|
});
|
|
|
|
let existing_stream_info = connection_data
|
|
.streams
|
|
.iter()
|
|
.position(|stream_info| match session_token {
|
|
Some(token) => {
|
|
stream_info.session_token.as_deref() == Some(token)
|
|
&& Self::should_reuse_stream_for_session(stream_info, stream_channel)
|
|
}
|
|
None => stream_info.addr == fingerprint.addr && stream_info.session_token.is_none(),
|
|
})
|
|
.map(|stream_idx| {
|
|
let session_started_at = session_token.and_then(|token| {
|
|
connection_data.sessions.iter()
|
|
.find(|s| s.token == token)
|
|
.map(|s| s.started_at)
|
|
});
|
|
|
|
let stream_info = &mut connection_data.streams[stream_idx];
|
|
let client_ip = fingerprint.client_ip.clone();
|
|
let preserve_started_at = stream_info.session_token.is_some()
|
|
&& (stream_info.channel.item_type.is_live_adaptive() || stream_channel.item_type.is_live_adaptive());
|
|
let was_preserved = stream_info.preserved;
|
|
let old_session_id = stream_history_session_id(stream_info.ts, stream_info.uid);
|
|
stream_info.meter_uid = meter_uid;
|
|
stream_info.addr = fingerprint.addr;
|
|
stream_info.client_ip.clone_from(&client_ip);
|
|
stream_info.country_code = self.lookup_country(&client_ip);
|
|
stream_info.channel = stream_channel.clone();
|
|
stream_info.provider = provider.clone();
|
|
stream_info.user_agent.clone_from(&user_agent_string);
|
|
|
|
if let Some(started_at) = session_started_at {
|
|
stream_info.started_at = started_at;
|
|
}
|
|
|
|
if preserve_started_at {
|
|
let now = current_time_secs();
|
|
if utc_day_from_secs(stream_info.ts) != utc_day_from_secs(now) {
|
|
stream_info.ts = now;
|
|
stream_info.previous_session_id = Some(old_session_id);
|
|
}
|
|
} else {
|
|
stream_info.ts = current_time_secs();
|
|
}
|
|
|
|
if let Some(token) = session_token {
|
|
stream_info.session_token = Some(token.to_string());
|
|
}
|
|
if was_preserved {
|
|
stream_info.preserved = false;
|
|
}
|
|
connection_data.stream_normal_priorities.insert(stream_info.uid, priority);
|
|
let result = stream_info.clone();
|
|
stream_info.previous_session_id = None;
|
|
(result, was_preserved)
|
|
});
|
|
let (stream_info, divergence_snapshot) = if let Some((stream_info, was_preserved)) = existing_stream_info {
|
|
let effective_connection_kind = reserved_session_kind.unwrap_or(connection_kind);
|
|
if was_preserved {
|
|
connection_data.increment_kind(effective_connection_kind);
|
|
}
|
|
connection_data.stream_kinds.insert(stream_info.uid, effective_connection_kind);
|
|
connection_data.stream_normal_priorities.insert(stream_info.uid, priority);
|
|
if let Some(token) = session_token {
|
|
if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) {
|
|
Self::mark_session_committed(session, effective_connection_kind);
|
|
}
|
|
}
|
|
let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username);
|
|
(stream_info, divergence_snapshot)
|
|
} else {
|
|
let effective_connection_kind = reserved_session_kind.unwrap_or(connection_kind);
|
|
let country_code = self.lookup_country(&fingerprint.client_ip);
|
|
|
|
let mut stream_info = StreamInfo::new(shared::model::StreamInfoParams {
|
|
uid,
|
|
meter_uid,
|
|
username,
|
|
addr: &fingerprint.addr,
|
|
client_ip: &fingerprint.client_ip,
|
|
provider,
|
|
stream_channel: stream_channel.clone(),
|
|
user_agent: user_agent_string,
|
|
country_code,
|
|
session_token,
|
|
});
|
|
|
|
if let Some(token) = session_token {
|
|
if let Some(session) = connection_data.sessions.iter().find(|s| s.token == token) {
|
|
stream_info.started_at = session.started_at;
|
|
}
|
|
}
|
|
|
|
if reserved_session_kind.is_none() {
|
|
connection_data.increment_kind(effective_connection_kind);
|
|
}
|
|
connection_data.streams.push(stream_info.clone());
|
|
connection_data.stream_kinds.insert(stream_info.uid, effective_connection_kind);
|
|
connection_data.stream_normal_priorities.insert(stream_info.uid, priority);
|
|
if let Some(token) = session_token {
|
|
if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) {
|
|
Self::mark_session_committed(session, effective_connection_kind);
|
|
}
|
|
}
|
|
Self::log_connection_added(username, &fingerprint.addr, connection_data, tracked_socket_count);
|
|
let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username);
|
|
(stream_info, divergence_snapshot)
|
|
};
|
|
let connection_count_changed = connection_data.connections != previous_connection_count;
|
|
(stream_info, divergence_snapshot, connection_count_changed)
|
|
};
|
|
|
|
self.log_divergence_snapshot(divergence_snapshot).await;
|
|
|
|
if connection_count_changed {
|
|
self.log_active_user().await;
|
|
}
|
|
|
|
Some(stream_info)
|
|
}
|
|
|
|
fn is_log_user_enabled(&self) -> bool { self.log_active_user.load(Ordering::Relaxed) }
|
|
|
|
fn build_preserved_stream_expiry(
|
|
&self,
|
|
username: &str,
|
|
stream: &StreamInfo,
|
|
sessions: &[UserSession],
|
|
) -> Option<AdaptiveExpiryEntry> {
|
|
let session_token = stream.session_token.as_deref()?;
|
|
let session = sessions.iter().find(|session| session.token == session_token)?;
|
|
|
|
let ttl_secs = self.adaptive_session_ttl_secs.load(Ordering::Relaxed);
|
|
let expires_at = session.ts.saturating_add(ttl_secs);
|
|
Some(AdaptiveExpiryEntry {
|
|
expires_at,
|
|
username: username.to_string(),
|
|
session_token: session_token.to_string(),
|
|
uid: stream.uid,
|
|
})
|
|
}
|
|
|
|
async fn enqueue_adaptive_expiry(&self, entry: AdaptiveExpiryEntry) {
|
|
let key = AdaptiveExpiryKey {
|
|
username: entry.username.clone(),
|
|
session_token: entry.session_token.clone(),
|
|
uid: entry.uid,
|
|
};
|
|
|
|
let mut expiry_index = self.adaptive_expiry_index.lock().await;
|
|
expiry_index.insert(key, entry.expires_at);
|
|
drop(expiry_index);
|
|
|
|
let mut queue = self.adaptive_expiry_queue.lock().await;
|
|
let wake_worker = queue.peek().is_none_or(|current| entry.expires_at < current.0.expires_at);
|
|
queue.push(Reverse(entry));
|
|
if wake_worker {
|
|
self.adaptive_expiry_notify.notify_one();
|
|
}
|
|
}
|
|
|
|
|
|
fn new_user_session(params: &UserSessionParams<'_>) -> UserSession {
|
|
let now = current_time_secs();
|
|
UserSession {
|
|
token: params.session_token.to_string(),
|
|
transition_version: 1,
|
|
virtual_id: params.virtual_id,
|
|
provider: params.provider.intern(),
|
|
stream_url: params.stream_url.intern(),
|
|
provider_session_headers: HashMap::new(),
|
|
addr: *params.addr,
|
|
socket_bound: params.socket_bound,
|
|
active_addrs: vec![*params.addr],
|
|
ts: now,
|
|
started_at: now,
|
|
permission: params.connection_permission,
|
|
connection_kind: params.connection_kind,
|
|
lifecycle: PlaybackLifecycle::Prepared,
|
|
}
|
|
}
|
|
|
|
fn promote_session_for_stream(connection_data: &mut UserConnectionData, stream: &StreamInfo) {
|
|
if let Some(token) = stream.session_token.as_deref() {
|
|
if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) {
|
|
Self::mark_session_committed(session, ConnectionKind::Normal);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn collect_promotions_after_capacity_release(connection_data: &mut UserConnectionData) -> Vec<PromotionAction> {
|
|
let mut promotions = Vec::new();
|
|
while let Some(action) = connection_data.try_promote_soft_stream() {
|
|
let promoted_stream = connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned();
|
|
if let Some(stream) = promoted_stream.as_ref() {
|
|
Self::promote_session_for_stream(connection_data, stream);
|
|
}
|
|
promotions.push(action);
|
|
}
|
|
while connection_data.try_promote_soft_session_reservation() {}
|
|
promotions
|
|
}
|
|
|
|
fn promote_counted_soft_session_to_normal_if_available(
|
|
connection_data: &mut UserConnectionData,
|
|
session_token: &str,
|
|
) -> Vec<PromotionAction> {
|
|
if connection_data.max_connections > 0 && connection_data.counts.normal >= connection_data.max_connections {
|
|
return Vec::new();
|
|
}
|
|
|
|
let Some(session_index) = connection_data.sessions.iter().position(|session| {
|
|
session.token == session_token
|
|
&& session.lifecycle.is_counted()
|
|
&& session.connection_kind == Some(ConnectionKind::Soft)
|
|
}) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
if connection_data.counts.soft == 0 {
|
|
return Vec::new();
|
|
}
|
|
|
|
connection_data.counts.normal = connection_data.counts.normal.saturating_add(1);
|
|
connection_data.counts.soft = connection_data.counts.soft.saturating_sub(1);
|
|
connection_data.sessions[session_index].connection_kind = Some(ConnectionKind::Normal);
|
|
Self::bump_session_transition_version(&mut connection_data.sessions[session_index]);
|
|
|
|
let mut promotions = Vec::new();
|
|
for stream in connection_data
|
|
.streams
|
|
.iter()
|
|
.filter(|stream| stream.session_token.as_deref() == Some(session_token))
|
|
{
|
|
if connection_data.stream_kinds.get(&stream.uid) != Some(&ConnectionKind::Soft) {
|
|
continue;
|
|
}
|
|
let new_priority = connection_data.stream_normal_priorities.get(&stream.uid).copied().unwrap_or_default();
|
|
connection_data.stream_kinds.insert(stream.uid, ConnectionKind::Normal);
|
|
promotions.push(PromotionAction {
|
|
addr: stream.addr,
|
|
uid: stream.uid,
|
|
new_priority,
|
|
});
|
|
}
|
|
promotions
|
|
}
|
|
|
|
fn bump_session_transition_version(session: &mut UserSession) -> u64 {
|
|
session.transition_version = session.transition_version.saturating_add(1);
|
|
session.transition_version
|
|
}
|
|
|
|
fn mark_session_committed(session: &mut UserSession, kind: ConnectionKind) {
|
|
session.connection_kind = Some(kind);
|
|
session.lifecycle = PlaybackLifecycle::Active;
|
|
Self::bump_session_transition_version(session);
|
|
}
|
|
|
|
fn update_session_admission(
|
|
session: &mut UserSession,
|
|
permission: UserConnectionPermission,
|
|
kind: Option<ConnectionKind>,
|
|
) {
|
|
session.permission = permission;
|
|
if let Some(kind) = kind {
|
|
session.connection_kind = Some(kind);
|
|
}
|
|
}
|
|
|
|
fn clear_session_pending_with_permission(
|
|
session: &mut UserSession,
|
|
permission: UserConnectionPermission,
|
|
wake_source: PendingProviderWakeSource,
|
|
) {
|
|
if let PlaybackLifecycle::PendingProvider { data } = &mut session.lifecycle {
|
|
data.wake_source = Some(wake_source);
|
|
}
|
|
Self::bump_session_transition_version(session);
|
|
session.permission = permission;
|
|
}
|
|
|
|
fn session_has_stream(connection_data: &UserConnectionData, session_token: &str) -> bool {
|
|
connection_data
|
|
.streams
|
|
.iter()
|
|
.any(|stream| stream.session_token.as_deref() == Some(session_token))
|
|
}
|
|
|
|
fn clear_session_counted_without_stream(connection_data: &mut UserConnectionData, session_token: &str) {
|
|
if Self::session_has_stream(connection_data, session_token) {
|
|
return;
|
|
}
|
|
if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == session_token) {
|
|
match session.lifecycle {
|
|
PlaybackLifecycle::Active => {
|
|
session.lifecycle = PlaybackLifecycle::Preserved;
|
|
}
|
|
// GraceActive without a stream: grace failed, expire the session.
|
|
// This can happen when the grace window times out while the client
|
|
// is still connecting but hasn't opened a stream yet.
|
|
PlaybackLifecycle::GraceActive => {
|
|
session.lifecycle = PlaybackLifecycle::Expired;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn clear_session_counted(connection_data: &mut UserConnectionData, session_token: &str) {
|
|
if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == session_token) {
|
|
match session.lifecycle {
|
|
PlaybackLifecycle::Active => {
|
|
session.lifecycle = PlaybackLifecycle::Preserved;
|
|
}
|
|
PlaybackLifecycle::GraceActive => {
|
|
// GraceActive without stream: the grace failed. The stream was already
|
|
// removed (this function is called after stream removal), so expire the session.
|
|
session.lifecycle = PlaybackLifecycle::Expired;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn release_expired_session_reservations(connection_data: &mut UserConnectionData, now: u64) {
|
|
let expired_counted = connection_data
|
|
.sessions
|
|
.iter()
|
|
.filter(|session| session.lifecycle.is_counted())
|
|
.filter(|session| now.saturating_sub(session.ts) >= USER_CON_TTL)
|
|
.filter(|session| !Self::session_has_stream(connection_data, session.token.as_str()))
|
|
.map(|session| (session.token.clone(), session.connection_kind.unwrap_or(ConnectionKind::Normal)))
|
|
.collect::<Vec<_>>();
|
|
|
|
for (_, kind) in &expired_counted {
|
|
connection_data.decrement_kind(*kind);
|
|
}
|
|
for (token, _) in expired_counted {
|
|
Self::clear_session_counted_without_stream(connection_data, &token);
|
|
}
|
|
while connection_data.try_promote_soft_session_reservation() {}
|
|
}
|
|
|
|
pub(crate) async fn connection_admission_for_session_activation(
|
|
&self,
|
|
username: &str,
|
|
max_connections: u32,
|
|
soft_connections: u16,
|
|
session_token: &str,
|
|
) -> ConnectionAdmission {
|
|
if max_connections == 0 && soft_connections == 0 {
|
|
return ConnectionAdmission {
|
|
permission: UserConnectionPermission::Allowed,
|
|
kind: Some(ConnectionKind::Normal),
|
|
};
|
|
}
|
|
|
|
let mut connections = self.connections.write().await;
|
|
let Some(connection_data) = connections.by_key.get_mut(username) else {
|
|
return ConnectionAdmission {
|
|
permission: UserConnectionPermission::Allowed,
|
|
kind: Some(ConnectionKind::Normal),
|
|
};
|
|
};
|
|
connection_data.max_connections = max_connections;
|
|
connection_data.soft_connections = soft_connections;
|
|
|
|
let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == session_token) else {
|
|
return self.check_connection_admission(username, connection_data);
|
|
};
|
|
|
|
// Existing counted session or session with an active stream: entitled to its slot.
|
|
if connection_data.sessions[session_index].lifecycle.is_counted() || Self::session_has_stream(connection_data, session_token) {
|
|
return ConnectionAdmission {
|
|
permission: UserConnectionPermission::Allowed,
|
|
kind: connection_data.sessions[session_index]
|
|
.connection_kind
|
|
.or(Some(ConnectionKind::Normal)),
|
|
};
|
|
}
|
|
|
|
// Existing uncounted session without a stream: check for a preserved stream.
|
|
// A preserved stream consumed a slot in the past and must remain evictable.
|
|
// Returning Exhausted here ensures eviction strategies are evaluated to free the
|
|
// preserved slot. This fixes the HLS->TS transition bug where preserved HLS
|
|
// streams were never evicted because strategy evaluation was skipped.
|
|
let has_preserved = connection_data
|
|
.streams
|
|
.iter()
|
|
.any(|s| s.session_token.as_deref() == Some(session_token) && s.preserved);
|
|
|
|
let admission = self.check_connection_admission_with_counts(
|
|
username,
|
|
connection_data,
|
|
connection_data.effective_counts_for_admission(Some(session_token)),
|
|
);
|
|
let kind = admission.kind.unwrap_or(ConnectionKind::Normal);
|
|
if admission.permission == UserConnectionPermission::Allowed {
|
|
let session = &mut connection_data.sessions[session_index];
|
|
Self::update_session_admission(session, admission.permission, Some(kind));
|
|
}
|
|
if has_preserved {
|
|
return ConnectionAdmission { permission: UserConnectionPermission::Exhausted, kind: admission.kind };
|
|
}
|
|
// Only consume the slot if admission was granted AND the session has a preserved stream
|
|
// that needs to be accounted for. Fresh uncounted sessions without streams stay
|
|
// uncounted until the provider commits a stream (via mark_session_committed).
|
|
if admission.permission == UserConnectionPermission::Allowed && has_preserved {
|
|
connection_data.increment_kind(kind);
|
|
}
|
|
admission
|
|
}
|
|
|
|
pub async fn ensure_user_session_placeholder(&self, request: CreateUserSessionParams<'_>) -> u64 {
|
|
let CreateUserSessionParams {
|
|
user,
|
|
session_token,
|
|
virtual_id,
|
|
provider,
|
|
stream_url,
|
|
addr,
|
|
connection_permission,
|
|
connection_kind,
|
|
socket_bound,
|
|
} = request;
|
|
self.gc();
|
|
|
|
let username = user.username.clone();
|
|
let mut user_connections = self.connections.write().await;
|
|
let connection_data = user_connections
|
|
.by_key
|
|
.entry(username.clone())
|
|
.or_insert_with(|| UserConnectionData::new(0, user.max_connections, user.soft_connections));
|
|
|
|
if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == session_token) {
|
|
session.ts = current_time_secs();
|
|
session.socket_bound = socket_bound;
|
|
remember_session_addr(session, *addr);
|
|
if session.connection_kind.is_none() {
|
|
session.connection_kind = connection_kind;
|
|
}
|
|
if session.permission == UserConnectionPermission::Exhausted {
|
|
Self::update_session_admission(session, connection_permission, None);
|
|
}
|
|
let version = Self::bump_session_transition_version(session);
|
|
let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, &username);
|
|
drop(user_connections);
|
|
self.log_divergence_snapshot(divergence_snapshot).await;
|
|
return version;
|
|
}
|
|
|
|
let session = Self::new_user_session(&UserSessionParams {
|
|
session_token,
|
|
virtual_id,
|
|
provider,
|
|
stream_url,
|
|
addr,
|
|
connection_permission,
|
|
connection_kind,
|
|
socket_bound,
|
|
});
|
|
let version = session.transition_version;
|
|
connection_data.add_session(session);
|
|
let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, &username);
|
|
drop(user_connections);
|
|
self.log_divergence_snapshot(divergence_snapshot).await;
|
|
version
|
|
}
|
|
|
|
pub async fn release_unbound_session_reservation(
|
|
&self,
|
|
username: &str,
|
|
session_token: &str,
|
|
expected_transition_version: Option<u64>,
|
|
remove_session_if_unbound: bool,
|
|
) {
|
|
let (connection_changed, user_removed, promotions, divergence_snapshot) = {
|
|
let mut user_connections = self.connections.write().await;
|
|
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
|
return;
|
|
};
|
|
|
|
if Self::session_has_stream(connection_data, session_token) {
|
|
return;
|
|
}
|
|
|
|
let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == session_token) else {
|
|
return;
|
|
};
|
|
|
|
if expected_transition_version.is_some_and(|expected| {
|
|
connection_data.sessions[session_index].transition_version != expected
|
|
}) {
|
|
return;
|
|
}
|
|
|
|
let mut connection_changed = false;
|
|
if connection_data.sessions[session_index].lifecycle.is_counted() {
|
|
let kind = connection_data.sessions[session_index]
|
|
.connection_kind
|
|
.unwrap_or(ConnectionKind::Normal);
|
|
connection_data.decrement_kind(kind);
|
|
connection_data.sessions[session_index].lifecycle = PlaybackLifecycle::Expired;
|
|
connection_changed = true;
|
|
}
|
|
connection_data.sessions[session_index].transition_version =
|
|
connection_data.sessions[session_index].transition_version.saturating_add(1);
|
|
|
|
if remove_session_if_unbound {
|
|
connection_data.sessions.swap_remove(session_index);
|
|
}
|
|
|
|
if connection_data.connections < connection_data.max_connections {
|
|
connection_data.granted_grace = false;
|
|
connection_data.grace_ts = 0;
|
|
}
|
|
|
|
let mut promotions = Vec::new();
|
|
while let Some(action) = connection_data.try_promote_soft_stream() {
|
|
let promoted_stream = connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned();
|
|
if let Some(stream) = promoted_stream.as_ref() {
|
|
Self::promote_session_for_stream(connection_data, stream);
|
|
}
|
|
promotions.push(action);
|
|
}
|
|
while connection_data.try_promote_soft_session_reservation() {}
|
|
|
|
let user_removed = connection_data.connections == 0
|
|
&& connection_data.streams.is_empty()
|
|
&& connection_data.sessions.is_empty();
|
|
let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username);
|
|
|
|
(connection_changed, user_removed, promotions, divergence_snapshot)
|
|
};
|
|
|
|
self.log_divergence_snapshot(divergence_snapshot).await;
|
|
|
|
if user_removed {
|
|
let mut user_connections = self.connections.write().await;
|
|
user_connections.by_key.remove(username);
|
|
}
|
|
if connection_changed || user_removed {
|
|
self.log_active_user().await;
|
|
}
|
|
for action in promotions {
|
|
self.emit_promotion_update(username, action).await;
|
|
}
|
|
}
|
|
|
|
pub async fn release_session_streams_and_counted_reservation(
|
|
&self,
|
|
username: &str,
|
|
session_token: &str,
|
|
) -> bool {
|
|
let (connection_changed, user_removed, promotions, divergence_snapshot) = {
|
|
let mut user_connections = self.connections.write().await;
|
|
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
|
return false;
|
|
};
|
|
|
|
let counted_kind = connection_data
|
|
.sessions
|
|
.iter()
|
|
.find(|session| session.token == session_token && session.lifecycle.is_counted())
|
|
.and_then(|session| session.connection_kind);
|
|
let (_removed_streams, mut connection_changed) =
|
|
connection_data.remove_streams_for_session_and_release_counted(session_token, counted_kind);
|
|
Self::clear_session_counted_without_stream(connection_data, session_token);
|
|
|
|
if connection_data.connections < connection_data.max_connections {
|
|
connection_data.granted_grace = false;
|
|
connection_data.grace_ts = 0;
|
|
}
|
|
|
|
let promotions = Self::collect_promotions_after_capacity_release(connection_data);
|
|
let user_removed = connection_data.connections == 0
|
|
&& connection_data.streams.is_empty()
|
|
&& connection_data.sessions.is_empty();
|
|
let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username);
|
|
connection_changed |= !promotions.is_empty();
|
|
|
|
(connection_changed, user_removed, promotions, divergence_snapshot)
|
|
};
|
|
|
|
self.log_divergence_snapshot(divergence_snapshot).await;
|
|
|
|
if user_removed {
|
|
let mut user_connections = self.connections.write().await;
|
|
user_connections.by_key.remove(username);
|
|
}
|
|
if connection_changed || user_removed {
|
|
self.log_active_user().await;
|
|
}
|
|
for action in promotions {
|
|
self.emit_promotion_update(username, action).await;
|
|
}
|
|
connection_changed || user_removed
|
|
}
|
|
|
|
pub async fn create_user_session(&self, request: CreateUserSessionParams<'_>) -> String {
|
|
let CreateUserSessionParams {
|
|
user,
|
|
session_token,
|
|
virtual_id,
|
|
provider,
|
|
stream_url,
|
|
addr,
|
|
connection_permission,
|
|
connection_kind,
|
|
socket_bound,
|
|
} = request;
|
|
self.gc();
|
|
|
|
let username = user.username.clone();
|
|
let mut user_connections = self.connections.write().await;
|
|
let connection_data = user_connections.by_key.entry(username.clone()).or_insert_with(|| {
|
|
debug_if_enabled!("Creating first session for user {username} {}", sanitize_sensitive_info(stream_url));
|
|
let mut data = UserConnectionData::new(0, user.max_connections, user.soft_connections);
|
|
let session =
|
|
Self::new_user_session(&UserSessionParams {
|
|
session_token,
|
|
virtual_id,
|
|
provider,
|
|
stream_url,
|
|
addr,
|
|
connection_permission,
|
|
connection_kind,
|
|
socket_bound,
|
|
});
|
|
data.add_session(session);
|
|
data
|
|
});
|
|
|
|
// If a session exists, update it
|
|
for session in &mut connection_data.sessions {
|
|
if session.token == session_token {
|
|
session.ts = current_time_secs();
|
|
session.socket_bound = socket_bound;
|
|
remember_session_addr(session, *addr);
|
|
Self::bump_session_transition_version(session);
|
|
let mut reset_provider_session_headers = false;
|
|
if &*session.stream_url != stream_url {
|
|
session.stream_url = stream_url.intern();
|
|
reset_provider_session_headers = true;
|
|
}
|
|
if &*session.provider != provider {
|
|
session.provider = provider.intern();
|
|
reset_provider_session_headers = true;
|
|
}
|
|
if reset_provider_session_headers {
|
|
session.provider_session_headers.clear();
|
|
}
|
|
// Normalize stale lifecycle states on session refresh.
|
|
// Expired, PendingProvider, and Preserved sessions cannot stay in those states
|
|
// when a new request arrives for the same session token - the request is either
|
|
// a reactivation (Activate) or a follow-up on a still-valid logical playback.
|
|
match session.lifecycle {
|
|
PlaybackLifecycle::Expired => {
|
|
session.lifecycle = PlaybackLifecycle::Prepared;
|
|
}
|
|
// PendingProvider: pending wait continues until explicitly resolved.
|
|
// Preserved: stays preserved until explicit reactivation via activation path.
|
|
// Prepared: placeholder session, no counted lease.
|
|
// Active: session is already in a valid counted state.
|
|
// All these keep their current state - session.refresh() alone does not advance it.
|
|
#[allow(clippy::match_same_arms)]
|
|
PlaybackLifecycle::PendingProvider { .. }
|
|
| PlaybackLifecycle::Preserved
|
|
| PlaybackLifecycle::Prepared
|
|
| PlaybackLifecycle::Active => {}
|
|
PlaybackLifecycle::GraceActive => {
|
|
// GraceActive refresh keeps the provisional state. Grace window is still
|
|
// running — refresh does not advance it. The grace task will resolve it.
|
|
}
|
|
}
|
|
Self::update_session_admission(session, connection_permission, connection_kind);
|
|
debug_if_enabled!(
|
|
"Using session for user {} with url: {}",
|
|
user.username,
|
|
sanitize_sensitive_info(stream_url)
|
|
);
|
|
return session.token.clone();
|
|
}
|
|
}
|
|
|
|
// If no session exists, create one
|
|
debug_if_enabled!(
|
|
"Creating session for user {} with url: {}",
|
|
user.username,
|
|
sanitize_sensitive_info(stream_url)
|
|
);
|
|
let session = Self::new_user_session(&UserSessionParams {
|
|
session_token,
|
|
virtual_id,
|
|
provider,
|
|
stream_url,
|
|
addr,
|
|
connection_permission,
|
|
connection_kind,
|
|
socket_bound,
|
|
});
|
|
let token = session.token.clone();
|
|
connection_data.add_session(session);
|
|
let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, &username);
|
|
drop(user_connections);
|
|
self.log_divergence_snapshot(divergence_snapshot).await;
|
|
token
|
|
}
|
|
|
|
pub async fn update_session_addr(&self, username: &str, token: &str, addr: &SocketAddr) {
|
|
let now = current_time_secs();
|
|
let mut user_connections = self.connections.write().await;
|
|
if let Some(connection_data) = user_connections.by_key.get_mut(username) {
|
|
let update_result = if let Some(session) = connection_data.sessions.iter_mut().find(|s| s.token == token) {
|
|
let previous_addr = session.addr;
|
|
remember_session_addr(session, *addr);
|
|
session.ts = now;
|
|
Self::bump_session_transition_version(session);
|
|
for stream in &mut connection_data.streams {
|
|
if stream.addr == previous_addr {
|
|
stream.addr = *addr;
|
|
stream.ts = now;
|
|
}
|
|
}
|
|
let prune_previous_registration = previous_addr != *addr
|
|
&& !connection_data.has_session_addr(&previous_addr)
|
|
&& !connection_data.streams.iter().any(|stream| stream.addr == previous_addr);
|
|
Some((previous_addr, prune_previous_registration))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some((previous_addr, prune_previous_registration)) = update_result {
|
|
if let Some(registration) = user_connections.key_by_addr.get_mut(addr) {
|
|
registration.ts = now;
|
|
registration.username = username.to_string();
|
|
} else {
|
|
user_connections.key_by_addr.insert(
|
|
*addr,
|
|
SocketRegistration {
|
|
username: username.to_string(),
|
|
ts: now,
|
|
},
|
|
);
|
|
}
|
|
if prune_previous_registration {
|
|
let can_remove_previous = user_connections
|
|
.key_by_addr
|
|
.get(&previous_addr)
|
|
.is_some_and(|registration| registration.username == username);
|
|
if can_remove_previous {
|
|
user_connections.key_by_addr.remove(&previous_addr);
|
|
}
|
|
}
|
|
debug_if_enabled!(
|
|
"Updated session {token} for {username} address {} -> {}",
|
|
sanitize_sensitive_info(&previous_addr.to_string()),
|
|
sanitize_sensitive_info(&addr.to_string())
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn clear_unbound_session_addr(&self, username: &str, token: &str, addr: &SocketAddr) {
|
|
let now = current_time_secs();
|
|
let mut user_connections = self.connections.write().await;
|
|
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
|
return;
|
|
};
|
|
let addr_has_active_stream_for_session = connection_data.streams.iter().any(|stream| {
|
|
stream.session_token.as_deref() == Some(token) && stream.addr == *addr && !stream.preserved
|
|
});
|
|
if addr_has_active_stream_for_session {
|
|
return;
|
|
}
|
|
|
|
let cleared = if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) {
|
|
let changed = clear_session_addr(session, addr);
|
|
if changed {
|
|
Self::bump_session_transition_version(session);
|
|
}
|
|
changed
|
|
} else {
|
|
false
|
|
};
|
|
if !cleared {
|
|
let can_remove_registration = !connection_data.has_session_addr(addr)
|
|
&& !connection_data.streams.iter().any(|stream| stream.addr == *addr);
|
|
if can_remove_registration {
|
|
let can_remove = user_connections
|
|
.key_by_addr
|
|
.get(addr)
|
|
.is_some_and(|registration| registration.username.is_empty() || registration.username == username);
|
|
if can_remove {
|
|
user_connections.key_by_addr.remove(addr);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
let can_remove_registration = !connection_data.has_session_addr(addr)
|
|
&& !connection_data.streams.iter().any(|stream| stream.addr == *addr);
|
|
if can_remove_registration {
|
|
let can_remove = user_connections
|
|
.key_by_addr
|
|
.get(addr)
|
|
.is_some_and(|registration| registration.username.is_empty() || registration.username == username);
|
|
if can_remove {
|
|
user_connections.key_by_addr.remove(addr);
|
|
}
|
|
} else if let Some(registration) = user_connections.key_by_addr.get_mut(addr) {
|
|
registration.ts = now;
|
|
}
|
|
}
|
|
|
|
pub async fn mark_pending_provider(
|
|
&self,
|
|
username: &str,
|
|
token: &str,
|
|
reason_code: PendingProviderReason,
|
|
deadline: u64,
|
|
) -> Option<u64> {
|
|
let mut user_connections = self.connections.write().await;
|
|
let connection_data = user_connections.by_key.get_mut(username)?;
|
|
let now = current_time_secs();
|
|
if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) {
|
|
let version = match session.lifecycle {
|
|
PlaybackLifecycle::PendingProvider { ref data } => data.version.saturating_add(1),
|
|
_ => 1,
|
|
};
|
|
// Capture counted status BEFORE lifecycle transition to PendingProvider.
|
|
// is_counted() returns false for PendingProvider, so we must check first.
|
|
let kind = if session.lifecycle.is_counted() {
|
|
Some(session.connection_kind.unwrap_or(ConnectionKind::Normal))
|
|
} else {
|
|
None
|
|
};
|
|
session.ts = now;
|
|
Self::bump_session_transition_version(session);
|
|
Self::update_session_admission(session, UserConnectionPermission::GracePeriod, None);
|
|
session.lifecycle = PlaybackLifecycle::PendingProvider {
|
|
data: PendingProviderState {
|
|
reason_code,
|
|
created_at: now,
|
|
deadline,
|
|
version,
|
|
wake_source: None,
|
|
},
|
|
};
|
|
if let Some(kind) = kind {
|
|
connection_data.decrement_kind(kind);
|
|
}
|
|
return Some(version);
|
|
}
|
|
None
|
|
}
|
|
|
|
pub async fn activate_pending_provider(
|
|
&self,
|
|
username: &str,
|
|
token: &str,
|
|
expected_version: u64,
|
|
wake_source: PendingProviderWakeSource,
|
|
) {
|
|
let mut user_connections = self.connections.write().await;
|
|
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
|
return;
|
|
};
|
|
if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) {
|
|
let PlaybackLifecycle::PendingProvider { data } = &mut session.lifecycle else {
|
|
return;
|
|
};
|
|
if data.version != expected_version {
|
|
return;
|
|
}
|
|
data.wake_source = Some(wake_source);
|
|
Self::bump_session_transition_version(session);
|
|
session.permission = UserConnectionPermission::Allowed;
|
|
session.lifecycle = PlaybackLifecycle::Active;
|
|
}
|
|
}
|
|
|
|
/// Returns the current `transition_version` if the session is in `GraceActive` lifecycle.
|
|
/// Used by the grace task to confirm the session is still in `GraceActive` before committing.
|
|
pub async fn grace_active_version(&self, username: &str, token: &str) -> Option<u64> {
|
|
let connections = self.connections.read().await;
|
|
let connection_data = connections.by_key.get(username)?;
|
|
let session = connection_data.sessions.iter().find(|s| s.token == token)?;
|
|
if session.lifecycle == PlaybackLifecycle::GraceActive {
|
|
Some(session.transition_version)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Marks a session as `GraceActive` — the session was granted immediate grace
|
|
/// (`GraceMode::Instant`) and is provisionally active. The session counts against
|
|
/// admission limits in this state.
|
|
///
|
|
/// This corresponds to `Prepared -> GraceActive` in the playback state machine.
|
|
/// The session remains in `GraceActive` until either:
|
|
/// - `activate_grace_active` confirms it (grace window succeeded -> `GraceActive -> Active`)
|
|
/// - `expire_grace_active` expires it (grace window failed -> `GraceActive -> Expired`)
|
|
pub async fn mark_grace_active(&self, username: &str, token: &str) {
|
|
let mut user_connections = self.connections.write().await;
|
|
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
|
return;
|
|
};
|
|
let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == token) else {
|
|
return;
|
|
};
|
|
if connection_data.sessions[session_index].lifecycle == PlaybackLifecycle::GraceActive {
|
|
return; // already grace active
|
|
}
|
|
// Collect fields while only borrowing sessions.
|
|
let kind = connection_data.sessions[session_index]
|
|
.connection_kind
|
|
.unwrap_or(ConnectionKind::Normal);
|
|
let needs_count = !connection_data.sessions[session_index].lifecycle.is_counted();
|
|
let now = current_time_secs();
|
|
// Now mutate. Use index access to avoid nested &mut borrows.
|
|
connection_data.sessions[session_index].ts = now;
|
|
Self::bump_session_transition_version(&mut connection_data.sessions[session_index]);
|
|
if needs_count {
|
|
connection_data.increment_kind(kind);
|
|
}
|
|
connection_data.sessions[session_index].lifecycle = PlaybackLifecycle::GraceActive;
|
|
}
|
|
|
|
/// Activates a `GraceActive` session when the grace window resolves successfully.
|
|
///
|
|
/// This corresponds to `GraceActive -> Active` in the playback state machine.
|
|
/// The session remains counted and the kind counts are already correct from
|
|
/// the `GraceActive` provisional state.
|
|
pub async fn activate_grace_active(&self, username: &str, token: &str, expected_version: u64) {
|
|
let mut user_connections = self.connections.write().await;
|
|
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
|
return;
|
|
};
|
|
if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) {
|
|
if session.transition_version != expected_version {
|
|
return;
|
|
}
|
|
if session.lifecycle != PlaybackLifecycle::GraceActive {
|
|
return;
|
|
}
|
|
Self::bump_session_transition_version(session);
|
|
session.lifecycle = PlaybackLifecycle::Active;
|
|
session.permission = UserConnectionPermission::Allowed;
|
|
}
|
|
}
|
|
|
|
/// Expires a `GraceActive` session when the grace window fails.
|
|
///
|
|
/// This corresponds to `GraceActive -> Expired` in the playback state machine.
|
|
/// Releases the provisional counted lease.
|
|
pub async fn expire_grace_active(&self, username: &str, token: &str, expected_version: u64) {
|
|
let (connection_changed, removed_count) = {
|
|
let mut user_connections = self.connections.write().await;
|
|
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
|
return;
|
|
};
|
|
let Some(session_index) = connection_data
|
|
.sessions
|
|
.iter()
|
|
.position(|session| session.token == token)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
if connection_data.sessions[session_index].transition_version != expected_version {
|
|
return;
|
|
}
|
|
if connection_data.sessions[session_index].lifecycle != PlaybackLifecycle::GraceActive {
|
|
return;
|
|
}
|
|
|
|
// Release the provisional counted lease using index-based access
|
|
// to avoid nested mutable borrows with connection_data methods.
|
|
let mut connection_changed = false;
|
|
let mut counted_kind: Option<ConnectionKind> = None;
|
|
if connection_data.sessions[session_index].lifecycle.is_counted() {
|
|
counted_kind = connection_data.sessions[session_index].connection_kind;
|
|
connection_changed = true;
|
|
}
|
|
if let Some(kind) = counted_kind {
|
|
connection_data.decrement_kind(kind);
|
|
}
|
|
|
|
// Expire the session. Lifecycle change alone handles counted state (Expired is not counted).
|
|
connection_data.sessions[session_index].lifecycle = PlaybackLifecycle::Expired;
|
|
connection_data.sessions[session_index].permission = UserConnectionPermission::Exhausted;
|
|
Self::bump_session_transition_version(&mut connection_data.sessions[session_index]);
|
|
|
|
// Collect addresses for stream cleanup.
|
|
let mut addrs = Vec::new();
|
|
let session_addr = connection_data.sessions[session_index].addr;
|
|
if !session_addr.ip().is_unspecified() {
|
|
addrs.push(session_addr);
|
|
}
|
|
for addr in &connection_data.sessions[session_index].active_addrs {
|
|
if *addr != session_addr && !addrs.contains(addr) {
|
|
addrs.push(*addr);
|
|
}
|
|
}
|
|
|
|
// Remove all streams for these addresses (never preserve on expire).
|
|
let mut removed_count = 0;
|
|
for addr in &addrs {
|
|
if let Some(stream_idx) = connection_data
|
|
.streams
|
|
.iter()
|
|
.position(|stream| stream.addr == *addr && !stream.preserved)
|
|
{
|
|
if let Some(kind) = connection_data.stream_kinds.remove(&connection_data.streams[stream_idx].uid) {
|
|
connection_data.decrement_kind(kind);
|
|
}
|
|
connection_data.stream_normal_priorities.remove(&connection_data.streams[stream_idx].uid);
|
|
connection_data.streams.swap_remove(stream_idx);
|
|
removed_count += 1;
|
|
}
|
|
}
|
|
|
|
// Reset grace if no connections remain.
|
|
if connection_data.connections == 0 && connection_data.streams.is_empty() {
|
|
connection_data.granted_grace = false;
|
|
connection_data.grace_ts = 0;
|
|
}
|
|
|
|
let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username);
|
|
drop(user_connections);
|
|
self.log_divergence_snapshot(divergence_snapshot).await;
|
|
|
|
(connection_changed, removed_count)
|
|
};
|
|
|
|
if connection_changed {
|
|
self.log_active_user().await;
|
|
}
|
|
debug!(
|
|
"GraceActive expired for session {token} in {username}, released {removed_count} streams"
|
|
);
|
|
}
|
|
|
|
/// Terminates the session and all associated streams for a playback.
|
|
///
|
|
/// This is the explicit `Terminate` path from the playback state machine:
|
|
/// - Removes all streams associated with this session token (never preserves)
|
|
/// - Releases the counted lease if held
|
|
/// - Sets lifecycle to `Expired`
|
|
/// - Clears pending-provider state
|
|
///
|
|
/// Unlike `release_unbound_session_reservation`, this terminates regardless of
|
|
/// whether streams are currently active, and always removes associated streams.
|
|
pub async fn terminate_session(&self, username: &str, session_token: &str) {
|
|
let (connection_changed, removed_count, promotions) = {
|
|
let mut user_connections = self.connections.write().await;
|
|
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
|
return;
|
|
};
|
|
|
|
let Some(session_index) = connection_data
|
|
.sessions
|
|
.iter()
|
|
.position(|session| session.token == session_token)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let counted_kind = connection_data.sessions[session_index]
|
|
.lifecycle
|
|
.is_counted()
|
|
.then(|| {
|
|
connection_data.sessions[session_index]
|
|
.connection_kind
|
|
.unwrap_or(ConnectionKind::Normal)
|
|
});
|
|
let (removed_count, connection_changed) = connection_data
|
|
.remove_streams_for_session_and_release_counted(session_token, counted_kind);
|
|
|
|
// Expire and remove the session immediately. Unlike `release_unbound_session_reservation`
|
|
// which keeps the expired session for TTL-based GC cleanup, terminate_session explicitly
|
|
// removes the session from the list so `get_and_update_user_session` returns None.
|
|
connection_data.sessions.swap_remove(session_index);
|
|
|
|
// Reset grace if no connections remain.
|
|
if connection_data.connections == 0 && connection_data.streams.is_empty() {
|
|
connection_data.granted_grace = false;
|
|
connection_data.grace_ts = 0;
|
|
}
|
|
|
|
let promotions = Self::collect_promotions_after_capacity_release(connection_data);
|
|
|
|
let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username);
|
|
drop(user_connections);
|
|
self.log_divergence_snapshot(divergence_snapshot).await;
|
|
|
|
(connection_changed, removed_count, promotions)
|
|
};
|
|
|
|
if connection_changed {
|
|
self.log_active_user().await;
|
|
}
|
|
for action in promotions {
|
|
self.emit_promotion_update(username, action).await;
|
|
}
|
|
debug!(
|
|
"Terminated session {session_token} for user {username}, released {removed_count} streams"
|
|
);
|
|
}
|
|
|
|
/// Terminates all sessions associated with a given socket address for a user.
|
|
///
|
|
/// This is used when a connection is explicitly kicked — the session should be
|
|
/// expired and removed immediately rather than waiting for TTL-based GC cleanup.
|
|
///
|
|
/// Removes all sessions whose `addr` or `active_addrs` contains `kick_addr`.
|
|
pub async fn terminate_sessions_for_addr(&self, username: &str, kick_addr: &SocketAddr) {
|
|
let (connection_changed, removed_count, promotions) = {
|
|
let mut user_connections = self.connections.write().await;
|
|
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
|
return;
|
|
};
|
|
|
|
// Collect tokens of sessions associated with the kicked addr.
|
|
let tokens_to_remove: Vec<String> = connection_data
|
|
.sessions
|
|
.iter()
|
|
.filter(|session| session.addr == *kick_addr || session.active_addrs.contains(kick_addr))
|
|
.map(|session| session.token.clone())
|
|
.collect();
|
|
|
|
if tokens_to_remove.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let mut removed_count = 0;
|
|
let mut connection_changed = false;
|
|
|
|
for token in &tokens_to_remove {
|
|
let Some(session_index) =
|
|
connection_data.sessions.iter().position(|s| s.token == *token)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
let counted_kind = connection_data.sessions[session_index]
|
|
.lifecycle
|
|
.is_counted()
|
|
.then(|| {
|
|
connection_data.sessions[session_index]
|
|
.connection_kind
|
|
.unwrap_or(ConnectionKind::Normal)
|
|
});
|
|
|
|
let (_, session_connection_changed) = connection_data
|
|
.remove_streams_for_session_and_release_counted(token, counted_kind);
|
|
connection_changed |= session_connection_changed;
|
|
|
|
// Expire and remove the session.
|
|
connection_data.sessions.swap_remove(session_index);
|
|
removed_count += 1;
|
|
}
|
|
|
|
// Reset grace if no connections remain.
|
|
if connection_data.connections == 0 && connection_data.streams.is_empty() {
|
|
connection_data.granted_grace = false;
|
|
connection_data.grace_ts = 0;
|
|
}
|
|
|
|
let promotions = Self::collect_promotions_after_capacity_release(connection_data);
|
|
|
|
let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username);
|
|
drop(user_connections);
|
|
self.log_divergence_snapshot(divergence_snapshot).await;
|
|
|
|
(connection_changed, removed_count, promotions)
|
|
};
|
|
|
|
if connection_changed {
|
|
self.log_active_user().await;
|
|
}
|
|
for action in promotions {
|
|
self.emit_promotion_update(username, action).await;
|
|
}
|
|
debug!(
|
|
"Terminated {removed_count} sessions for user {username} at addr {kick_addr}"
|
|
);
|
|
}
|
|
|
|
pub async fn expire_pending_provider(
|
|
&self,
|
|
username: &str,
|
|
token: &str,
|
|
expected_version: u64,
|
|
wake_source: PendingProviderWakeSource,
|
|
) {
|
|
let mut user_connections = self.connections.write().await;
|
|
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
|
return;
|
|
};
|
|
let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == token) else {
|
|
return;
|
|
};
|
|
let pending_version = match &connection_data.sessions[session_index].lifecycle {
|
|
PlaybackLifecycle::PendingProvider { data } => data.version,
|
|
_ => return,
|
|
};
|
|
if pending_version != expected_version {
|
|
return;
|
|
}
|
|
// Capture counted status BEFORE lifecycle changes.
|
|
// PendingProvider is not counted (is_counted() = false), so checking here
|
|
// captures whether there is a previously-counted lease to release.
|
|
let kind_to_release = if connection_data.sessions[session_index].lifecycle.is_counted() {
|
|
Some(
|
|
connection_data.sessions[session_index]
|
|
.connection_kind
|
|
.unwrap_or(ConnectionKind::Normal),
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
let session = &mut connection_data.sessions[session_index];
|
|
Self::clear_session_pending_with_permission(session, UserConnectionPermission::Exhausted, wake_source);
|
|
session.lifecycle = PlaybackLifecycle::Expired;
|
|
if let Some(kind) = kind_to_release {
|
|
connection_data.decrement_kind(kind);
|
|
}
|
|
}
|
|
|
|
pub async fn adaptive_session_stream_cleanup_addrs(
|
|
&self,
|
|
username: &str,
|
|
session_token: &str,
|
|
current_addr: &SocketAddr,
|
|
) -> Vec<SocketAddr> {
|
|
let connections = self.connections.read().await;
|
|
let Some(connection_data) = connections.by_key.get(username) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let mut addrs = Vec::new();
|
|
for stream in connection_data
|
|
.streams
|
|
.iter()
|
|
.filter(|stream| stream.session_token.as_deref() == Some(session_token))
|
|
{
|
|
if stream.addr != *current_addr && !addrs.contains(&stream.addr) {
|
|
addrs.push(stream.addr);
|
|
}
|
|
}
|
|
let current_addr_string = current_addr.to_string();
|
|
let current_ip = strip_port(¤t_addr_string).to_string();
|
|
if let Some(session) = connection_data.sessions.iter().find(|session| session.token == session_token) {
|
|
for addr in &session.active_addrs {
|
|
let addr_string = addr.to_string();
|
|
let addr_ip = strip_port(&addr_string);
|
|
if *addr != *current_addr && addr_ip == current_ip && !addrs.contains(addr) {
|
|
addrs.push(*addr);
|
|
}
|
|
}
|
|
}
|
|
addrs
|
|
}
|
|
|
|
pub fn active_socket_ttl_secs(&self) -> u64 {
|
|
let configured_ttl = self.adaptive_session_ttl_secs.load(Ordering::Relaxed);
|
|
if configured_ttl == 0 { DEFAULT_ACTIVE_SOCKET_TTL_SECS } else { configured_ttl }
|
|
}
|
|
|
|
pub async fn socket_expiry_deadline(&self, addr: &SocketAddr) -> Option<u64> {
|
|
let ttl_secs = self.active_socket_ttl_secs();
|
|
let connections = self.connections.read().await;
|
|
let registration = connections.key_by_addr.get(addr)?;
|
|
if registration.username.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(registration.ts.saturating_add(ttl_secs))
|
|
}
|
|
|
|
pub async fn touch_socket_activity(&self, addr: &SocketAddr) {
|
|
let now = current_time_secs();
|
|
let mut user_connections = self.connections.write().await;
|
|
let Some(username) = user_connections.key_by_addr.get_mut(addr).and_then(|registration| {
|
|
if registration.username.is_empty() {
|
|
None
|
|
} else {
|
|
registration.ts = now;
|
|
Some(registration.username.clone())
|
|
}
|
|
}) else {
|
|
return;
|
|
};
|
|
|
|
if let Some(connection_data) = user_connections.by_key.get_mut(&username) {
|
|
connection_data.ts = now;
|
|
}
|
|
}
|
|
|
|
pub async fn touch_http_activity(&self, username: &str, token: &str, addr: &SocketAddr) {
|
|
let now = current_time_secs();
|
|
let mut user_connections = self.connections.write().await;
|
|
|
|
let registration = user_connections
|
|
.key_by_addr
|
|
.entry(*addr)
|
|
.or_insert_with(SocketRegistration::anonymous);
|
|
registration.username = username.to_string();
|
|
registration.ts = now;
|
|
|
|
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
|
return;
|
|
};
|
|
|
|
connection_data.ts = now;
|
|
|
|
for session in &mut connection_data.sessions {
|
|
if session.token == token {
|
|
// Lightweight HTTP activity (for example HLS manifest reloads) refreshes
|
|
// continuity metadata only. It must not become an active stream socket:
|
|
// otherwise a manifest or probe request can steal the visible stream addr,
|
|
// and the real segment socket later migrates to that stale addr instead of
|
|
// being released/preserved.
|
|
session.ts = now;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn get_and_update_user_session(&self, username: &str, token: &str) -> Option<UserSession> {
|
|
self.update_user_session(username, token).await
|
|
}
|
|
|
|
pub async fn update_session_provider_headers(
|
|
&self,
|
|
username: &str,
|
|
token: &str,
|
|
provider_session_headers: &HashMap<String, String>,
|
|
) -> bool {
|
|
let mut user_connections = self.connections.write().await;
|
|
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
|
return false;
|
|
};
|
|
let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) else {
|
|
return false;
|
|
};
|
|
session.provider_session_headers.clone_from(provider_session_headers);
|
|
session.ts = current_time_secs();
|
|
true
|
|
}
|
|
|
|
pub async fn pending_provider_version(&self, username: &str, token: &str) -> Option<u64> {
|
|
let user_connections = self.connections.read().await;
|
|
let connection_data = user_connections.by_key.get(username)?;
|
|
let session = connection_data.sessions.iter().find(|session| session.token == token)?;
|
|
match &session.lifecycle {
|
|
PlaybackLifecycle::PendingProvider { data } => Some(data.version),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn update_user_session(&self, username: &str, token: &str) -> Option<UserSession> {
|
|
let mut user_connections = self.connections.write().await;
|
|
|
|
let connection_data = user_connections.by_key.get_mut(username)?;
|
|
let now = current_time_secs();
|
|
|
|
connection_data.ts = now;
|
|
|
|
let session_index = connection_data.sessions.iter().position(|s| s.token == token)?;
|
|
|
|
connection_data.sessions[session_index].ts = now;
|
|
|
|
if connection_data.max_connections > 0
|
|
&& connection_data.sessions[session_index].permission == UserConnectionPermission::GracePeriod
|
|
&& !matches!(connection_data.sessions[session_index].lifecycle, PlaybackLifecycle::PendingProvider { .. })
|
|
{
|
|
let admission = self.check_connection_admission(username, connection_data);
|
|
connection_data.sessions[session_index].permission = admission.permission;
|
|
if admission.kind.is_some() {
|
|
connection_data.sessions[session_index].connection_kind = admission.kind;
|
|
}
|
|
}
|
|
|
|
Some(connection_data.sessions[session_index].clone())
|
|
}
|
|
|
|
pub async fn active_streams(&self) -> Vec<StreamInfo> {
|
|
self.gc();
|
|
let user_connections = self.connections.read().await;
|
|
let mut streams = Vec::new();
|
|
for connection_data in user_connections.by_key.values() {
|
|
for stream in &connection_data.streams {
|
|
if !stream.preserved {
|
|
streams.push(stream.clone());
|
|
}
|
|
}
|
|
}
|
|
streams
|
|
}
|
|
|
|
fn log_connection_added(
|
|
username: &str,
|
|
addr: &SocketAddr,
|
|
connection_data: &UserConnectionData,
|
|
tracked_socket_count: usize,
|
|
) {
|
|
if log::log_enabled!(log::Level::Debug) {
|
|
let active_for_user = connection_data.connections;
|
|
if connection_data.max_connections > 0 && active_for_user > connection_data.max_connections {
|
|
let recent_sockets = connection_data
|
|
.streams
|
|
.iter()
|
|
.rev()
|
|
.take(3)
|
|
.map(|stream| stream.addr.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let recent_sockets = if recent_sockets.is_empty() { String::from("n/a") } else { recent_sockets };
|
|
let unique_clients =
|
|
connection_data.streams.iter().map(|stream| &stream.client_ip).collect::<HashSet<_>>().len();
|
|
debug!(
|
|
"User {username} exceeded configured max connections ({}/{}). Unique clients: {}, recent sockets [{}]",
|
|
active_for_user,
|
|
connection_data.max_connections,
|
|
unique_clients,
|
|
recent_sockets
|
|
);
|
|
} else {
|
|
debug_if_enabled!(
|
|
"Added new connection for {username} at {} (active user connections={active_for_user}, tracked sockets={tracked_socket_count})",
|
|
sanitize_sensitive_info(&addr.to_string())
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn is_user_blocked_for_stream(&self, username: &str, virtual_id: VirtualId) -> bool {
|
|
let connections = self.connections.read().await;
|
|
let now = current_time_secs();
|
|
matches!(connections.kicked.get(username), Some((expires_at, vid)) if *vid == virtual_id && *expires_at > now)
|
|
}
|
|
|
|
pub async fn recently_evicted_session_protected_addr(&self, session_token: &str) -> Option<SocketAddr> {
|
|
let connections = self.connections.read().await;
|
|
let now = current_time_secs();
|
|
let protection = connections.recently_evicted_sessions.get(session_token)?;
|
|
if protection.expires_at > now {
|
|
return Some(protection.protected_addr);
|
|
}
|
|
|
|
let username = connections.by_key.iter().find_map(|(username, connection_data)| {
|
|
connection_data
|
|
.sessions
|
|
.iter()
|
|
.any(|session| session.token == session_token)
|
|
.then_some(username.as_str())
|
|
})?;
|
|
connections
|
|
.key_by_addr
|
|
.get(&protection.protected_addr)
|
|
.filter(|registration| registration.username == username)
|
|
.map(|_| protection.protected_addr)
|
|
}
|
|
|
|
pub async fn recent_socket_reentry_protected_addr(
|
|
&self,
|
|
username: &str,
|
|
client_ip: &str,
|
|
virtual_id: VirtualId,
|
|
) -> Option<SocketAddr> {
|
|
let connections = self.connections.read().await;
|
|
let now = current_time_secs();
|
|
let key = create_socket_reentry_guard_key(username, client_ip, virtual_id);
|
|
let protection = connections.recent_socket_reentry_guards.get(&key)?;
|
|
if protection.expires_at > now {
|
|
return Some(protection.protected_addr);
|
|
}
|
|
|
|
connections
|
|
.key_by_addr
|
|
.get(&protection.protected_addr)
|
|
.filter(|registration| registration.username == username)
|
|
.map(|_| protection.protected_addr)
|
|
}
|
|
|
|
pub async fn block_user_for_stream(&self, addr: &SocketAddr, virtual_id: VirtualId, blocked_secs: u64) {
|
|
let block_for_secs = blocked_secs.clamp(0, 86_400); // max 1 day;
|
|
if block_for_secs > 0 {
|
|
let mut connections = self.connections.write().await;
|
|
let now = current_time_secs();
|
|
connections.kicked.retain(|_, (expires_at, _)| *expires_at > now);
|
|
if let Some(username) = connections
|
|
.key_by_addr
|
|
.get(addr)
|
|
.map(|registration| registration.username.clone())
|
|
.filter(|username| !username.is_empty())
|
|
{
|
|
let expires_at = now + block_for_secs;
|
|
connections.kicked.insert(username, (expires_at, virtual_id));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn mark_recent_eviction_guard_for_addr(
|
|
&self,
|
|
addr: &SocketAddr,
|
|
protected_addr: SocketAddr,
|
|
ttl_secs: u64,
|
|
) {
|
|
if ttl_secs == 0 {
|
|
return;
|
|
}
|
|
|
|
let mut connections = self.connections.write().await;
|
|
let now = current_time_secs();
|
|
connections
|
|
.recently_evicted_sessions
|
|
.retain(|_, protection| protection.expires_at > now);
|
|
connections
|
|
.recent_socket_reentry_guards
|
|
.retain(|_, protection| protection.expires_at > now);
|
|
|
|
let Some(username) = connections
|
|
.key_by_addr
|
|
.get(addr)
|
|
.map(|registration| registration.username.clone())
|
|
.filter(|username| !username.is_empty())
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let Some(connection_data) = connections.by_key.get(&username) else {
|
|
return;
|
|
};
|
|
|
|
let protection = RecentWinnerProtection {
|
|
protected_addr,
|
|
expires_at: now + ttl_secs,
|
|
};
|
|
let mut session_tokens = Vec::new();
|
|
let mut socket_guard_keys = Vec::new();
|
|
|
|
for stream in connection_data.streams.iter().filter(|stream| stream.addr == *addr) {
|
|
if uses_session_reentry_guard(stream) && stream.session_token.is_some() {
|
|
let Some(session_token) = stream.session_token.clone() else {
|
|
continue;
|
|
};
|
|
session_tokens.push(session_token);
|
|
} else {
|
|
socket_guard_keys.push(create_socket_reentry_guard_key(
|
|
&username,
|
|
&stream.client_ip,
|
|
stream.channel.virtual_id,
|
|
));
|
|
}
|
|
}
|
|
|
|
for session_token in session_tokens {
|
|
connections
|
|
.recently_evicted_sessions
|
|
.insert(session_token, protection);
|
|
}
|
|
for key in socket_guard_keys {
|
|
connections
|
|
.recent_socket_reentry_guards
|
|
.insert(key, protection);
|
|
}
|
|
}
|
|
|
|
pub async fn get_username_for_addr(&self, addr: &SocketAddr) -> Option<String> {
|
|
self.connections
|
|
.read()
|
|
.await
|
|
.key_by_addr
|
|
.get(addr)
|
|
.map(|registration| registration.username.clone())
|
|
}
|
|
|
|
fn should_preserve_session_stream(stream: &StreamInfo) -> bool {
|
|
stream.session_token.is_some() && is_stable_session_stream(stream)
|
|
}
|
|
|
|
fn is_preserved_stream_expired(
|
|
&self,
|
|
stream: &StreamInfo,
|
|
sessions: &[UserSession],
|
|
now: u64,
|
|
) -> bool {
|
|
if !stream.preserved || !Self::should_preserve_session_stream(stream) {
|
|
return false;
|
|
}
|
|
|
|
let ttl_secs = self.adaptive_session_ttl_secs.load(Ordering::Relaxed);
|
|
let Some(session_token) = stream.session_token.as_deref() else {
|
|
return true;
|
|
};
|
|
|
|
let Some(session) = sessions.iter().find(|session| session.token == session_token) else {
|
|
return true;
|
|
};
|
|
|
|
now.saturating_sub(session.ts) >= ttl_secs
|
|
}
|
|
|
|
fn collect_divergence_snapshot(connection_data: &UserConnectionData, username: &str) -> DivergenceSnapshot {
|
|
let connections = connection_data.connections;
|
|
let counted_sessions = connection_data.sessions.iter().filter(|s| s.lifecycle.is_counted()).count();
|
|
let streams_count = connection_data.streams.len();
|
|
let mut kinds = Vec::new();
|
|
|
|
for session in &connection_data.sessions {
|
|
if !session.lifecycle.is_counted() {
|
|
continue;
|
|
}
|
|
if matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { ref data } if data.reason_code == PendingProviderReason::GraceHold) {
|
|
continue;
|
|
}
|
|
let has_active_stream = connection_data
|
|
.streams
|
|
.iter()
|
|
.any(|s| s.session_token.as_deref() == Some(&session.token) && !s.preserved);
|
|
if !has_active_stream {
|
|
kinds.push(DivergenceKind::CountedSessionWithoutStream);
|
|
}
|
|
}
|
|
|
|
for stream in &connection_data.streams {
|
|
if stream.preserved {
|
|
continue;
|
|
}
|
|
let Some(token) = stream.session_token.as_deref() else {
|
|
continue;
|
|
};
|
|
let has_counted_session = connection_data.sessions.iter().any(|s| s.token == token && s.lifecycle.is_counted());
|
|
if !has_counted_session {
|
|
kinds.push(DivergenceKind::StreamWithoutCountedSession);
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::cast_possible_truncation)]
|
|
let counted_sessions_u32 = counted_sessions as u32;
|
|
if connections != counted_sessions_u32 {
|
|
kinds.push(DivergenceKind::ConnectionCountMismatch {
|
|
legacy: connections,
|
|
counted: counted_sessions_u32,
|
|
});
|
|
}
|
|
|
|
DivergenceSnapshot {
|
|
username: username.to_string(),
|
|
connections,
|
|
counted_sessions,
|
|
streams_count,
|
|
kinds,
|
|
}
|
|
}
|
|
|
|
async fn log_divergence_snapshot(&self, snapshot: DivergenceSnapshot) {
|
|
let cooldown = Duration::from_secs(self.divergence_cooldown_secs);
|
|
for kind in &snapshot.kinds {
|
|
let key = divergence_key(&snapshot.username, kind);
|
|
let should_log = {
|
|
let mut cache = self.divergence_cache.lock().await;
|
|
if let Some(entry) = cache.get_mut(&key) {
|
|
if entry.last_logged.elapsed() >= cooldown {
|
|
entry.last_logged = Instant::now();
|
|
entry.count_since_last_log = 0;
|
|
true
|
|
} else {
|
|
entry.count_since_last_log = entry.count_since_last_log.saturating_add(1);
|
|
false
|
|
}
|
|
} else {
|
|
cache.push(
|
|
key,
|
|
DivergenceEntry {
|
|
last_logged: Instant::now(),
|
|
count_since_last_log: 0,
|
|
},
|
|
);
|
|
true
|
|
}
|
|
};
|
|
|
|
if should_log {
|
|
debug!(
|
|
"ADMISSION DIVERGENCE user={} kind={kind:?} connections={} counted_sessions={} streams={}",
|
|
snapshot.username,
|
|
snapshot.connections,
|
|
snapshot.counted_sessions,
|
|
snapshot.streams_count,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn check_and_log_divergence_for_user(&self, username: &str) {
|
|
let snapshot = {
|
|
let connections = self.connections.read().await;
|
|
let Some(data) = connections.by_key.get(username) else {
|
|
return;
|
|
};
|
|
Self::collect_divergence_snapshot(data, username)
|
|
};
|
|
self.log_divergence_snapshot(snapshot).await;
|
|
}
|
|
|
|
async fn run_adaptive_expiry_worker(self: Arc<Self>) {
|
|
loop {
|
|
let next_expiry = {
|
|
let queue = self.adaptive_expiry_queue.lock().await;
|
|
queue.peek().map(|entry| entry.0.expires_at)
|
|
};
|
|
|
|
match next_expiry {
|
|
None => {
|
|
tokio::select! {
|
|
() = self.adaptive_expiry_notify.notified() => {}
|
|
() = self.adaptive_expiry_cancel.cancelled() => break,
|
|
}
|
|
}
|
|
Some(expires_at) => {
|
|
let now = current_time_secs();
|
|
if expires_at <= now {
|
|
self.process_due_adaptive_expiry_entries(now).await;
|
|
continue;
|
|
}
|
|
|
|
tokio::select! {
|
|
() = tokio::time::sleep(Duration::from_secs(expires_at.saturating_sub(now))) => {}
|
|
() = self.adaptive_expiry_notify.notified() => {}
|
|
() = self.adaptive_expiry_cancel.cancelled() => break,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn process_due_adaptive_expiry_entries(&self, now: u64) {
|
|
let mut due_entries = Vec::new();
|
|
{
|
|
let mut queue = self.adaptive_expiry_queue.lock().await;
|
|
while let Some(entry) = queue.peek() {
|
|
if entry.0.expires_at > now {
|
|
break;
|
|
}
|
|
if let Some(Reverse(entry)) = queue.pop() {
|
|
due_entries.push(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
if due_entries.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let usernames_to_check: std::collections::HashSet<_> = due_entries.iter().map(|e| &e.username).collect();
|
|
|
|
let mut removed_addrs: Vec<std::net::SocketAddr> = Vec::new();
|
|
let mut cleanup_events: Vec<(std::net::SocketAddr, Box<StreamInfo>)> = Vec::new();
|
|
let mut replacement_entries: Vec<AdaptiveExpiryEntry> = Vec::new();
|
|
let mut promotions: Vec<(String, PromotionAction)> = Vec::new();
|
|
{
|
|
let mut expiry_index = self.adaptive_expiry_index.lock().await;
|
|
let mut user_connections = self.connections.write().await;
|
|
for entry in &due_entries {
|
|
let key = AdaptiveExpiryKey {
|
|
username: entry.username.clone(),
|
|
session_token: entry.session_token.clone(),
|
|
uid: entry.uid,
|
|
};
|
|
let Some(current_expires_at) = expiry_index.get(&key).copied() else {
|
|
continue;
|
|
};
|
|
if current_expires_at != entry.expires_at {
|
|
continue;
|
|
}
|
|
|
|
let mut remove_user = false;
|
|
if let Some(connection_data) = user_connections.by_key.get_mut(&entry.username) {
|
|
let stream_idx_opt = connection_data
|
|
.streams
|
|
.iter()
|
|
.position(|stream| {
|
|
stream.uid == entry.uid
|
|
&& stream.preserved
|
|
&& stream.session_token.as_deref() == Some(entry.session_token.as_str())
|
|
});
|
|
|
|
if let Some(stream_idx) = stream_idx_opt {
|
|
let should_remove = self.is_preserved_stream_expired(
|
|
&connection_data.streams[stream_idx],
|
|
&connection_data.sessions,
|
|
now,
|
|
);
|
|
|
|
if should_remove {
|
|
let addr = connection_data.streams[stream_idx].addr;
|
|
let session_token = connection_data.streams[stream_idx].session_token.clone();
|
|
if self.cleanup_tx.get().is_some() {
|
|
cleanup_events.push((addr, Box::new(connection_data.streams[stream_idx].clone())));
|
|
} else {
|
|
removed_addrs.push(addr);
|
|
}
|
|
let removed_stream = connection_data.streams.swap_remove(stream_idx);
|
|
if let Some(kind) = connection_data.stream_kinds.remove(&removed_stream.uid) {
|
|
connection_data.decrement_kind(kind);
|
|
}
|
|
connection_data.stream_normal_priorities.remove(&removed_stream.uid);
|
|
if let Some(action) = connection_data.try_promote_soft_stream() {
|
|
let promoted_stream =
|
|
connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned();
|
|
if let Some(stream) = promoted_stream.as_ref() {
|
|
Self::promote_session_for_stream(connection_data, stream);
|
|
}
|
|
promotions.push((entry.username.clone(), action));
|
|
}
|
|
if let Some(session_token) = session_token.as_deref() {
|
|
Self::clear_session_counted_without_stream(connection_data, session_token);
|
|
}
|
|
while connection_data.try_promote_soft_session_reservation() {}
|
|
expiry_index.remove(&key);
|
|
} else if let Some(replacement_entry) = self.build_preserved_stream_expiry(
|
|
&entry.username,
|
|
&connection_data.streams[stream_idx],
|
|
&connection_data.sessions,
|
|
) {
|
|
if replacement_entry.expires_at != current_expires_at {
|
|
replacement_entries.push(replacement_entry);
|
|
}
|
|
}
|
|
} else {
|
|
expiry_index.remove(&key);
|
|
}
|
|
|
|
remove_user = connection_data.connections == 0
|
|
&& connection_data.streams.is_empty()
|
|
&& connection_data.sessions.is_empty();
|
|
} else {
|
|
expiry_index.remove(&key);
|
|
}
|
|
|
|
if remove_user {
|
|
user_connections.by_key.remove(&entry.username);
|
|
}
|
|
}
|
|
} // locks released here
|
|
|
|
// divergence check after adaptive expiry processing
|
|
for username in usernames_to_check {
|
|
let snapshot = {
|
|
let connections = self.connections.read().await;
|
|
connections
|
|
.by_key
|
|
.get(username)
|
|
.map(|data| Self::collect_divergence_snapshot(data, username))
|
|
};
|
|
if let Some(snapshot) = snapshot {
|
|
self.log_divergence_snapshot(snapshot).await;
|
|
}
|
|
}
|
|
|
|
if let Some(tx) = self.cleanup_tx.get() {
|
|
for (addr, stream_info) in cleanup_events {
|
|
if tx.try_send(CleanupEvent::AdaptiveSessionExpired { stream_info }).is_err() {
|
|
self.dropped_cleanup_events.fetch_add(1, Ordering::Relaxed);
|
|
debug!("Cleanup channel unavailable, dropping adaptive session expiry");
|
|
removed_addrs.push(addr);
|
|
}
|
|
}
|
|
}
|
|
|
|
for entry in replacement_entries {
|
|
self.enqueue_adaptive_expiry(entry).await;
|
|
}
|
|
|
|
for (username, action) in promotions {
|
|
self.emit_promotion_update(&username, action).await;
|
|
}
|
|
|
|
let had_removals = !removed_addrs.is_empty();
|
|
for addr in removed_addrs {
|
|
self.event_manager
|
|
.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Disconnected(addr)));
|
|
}
|
|
if had_removals {
|
|
self.log_active_user().await;
|
|
}
|
|
}
|
|
|
|
fn gc(&self) {
|
|
if let Some(gc_ts) = &self.gc_ts {
|
|
let ts = gc_ts.load(Ordering::Acquire);
|
|
let now = current_time_secs();
|
|
|
|
if now.saturating_sub(ts) > USER_GC_TTL
|
|
&& gc_ts.compare_exchange(ts, now, Ordering::AcqRel, Ordering::Relaxed).is_ok()
|
|
{
|
|
if let Ok(mut user_connections) = self.connections.try_write() {
|
|
user_connections.kicked.retain(|_, (expires_at, _)| *expires_at > now);
|
|
user_connections
|
|
.recently_evicted_sessions
|
|
.retain(|_, protection| protection.expires_at > now);
|
|
user_connections
|
|
.recent_socket_reentry_guards
|
|
.retain(|_, protection| protection.expires_at > now);
|
|
for connection_data in user_connections.by_key.values_mut() {
|
|
Self::release_expired_session_reservations(connection_data, now);
|
|
connection_data.sessions.retain(|s| now.saturating_sub(s.ts) < USER_CON_TTL);
|
|
}
|
|
user_connections.by_key.retain(|_k, v| {
|
|
v.connections > 0 || !v.streams.is_empty() || now.saturating_sub(v.ts) < USER_CON_TTL
|
|
});
|
|
user_connections.key_by_addr.retain(|_, registration| {
|
|
!(registration.username.is_empty() && now.saturating_sub(registration.ts) >= ANON_SOCKET_TTL)
|
|
});
|
|
} else {
|
|
// Lock contention: release the GC claim so a subsequent caller can retry immediately.
|
|
let _ = gc_ts.compare_exchange(now, ts, Ordering::AcqRel, Ordering::Relaxed);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{api::model::EventManager, auth::Fingerprint, model::{Config, ProxyUserCredentials}};
|
|
use arc_swap::ArcSwapOption;
|
|
use shared::{
|
|
model::{PlaylistItemType, StreamChannel, StreamInfo, XtreamCluster},
|
|
utils::Internable,
|
|
};
|
|
use std::{borrow::Cow, collections::HashMap, sync::Arc};
|
|
use shared::model::ProxyType;
|
|
|
|
fn test_channel(virtual_id: u32) -> StreamChannel {
|
|
StreamChannel {
|
|
target_id: 1,
|
|
virtual_id,
|
|
provider_id: 1,
|
|
input_name: "input".intern(),
|
|
item_type: PlaylistItemType::Live,
|
|
cluster: XtreamCluster::Live,
|
|
group: "group".intern(),
|
|
title: "title".intern(),
|
|
url: "http://localhost/stream.ts".intern(),
|
|
shared: false,
|
|
shared_joined_existing: None,
|
|
shared_stream_id: None,
|
|
technical: None,
|
|
epg_channel_id: None,
|
|
epg_reference_ts: None,
|
|
}
|
|
}
|
|
|
|
fn test_adaptive_channel(virtual_id: u32) -> StreamChannel {
|
|
StreamChannel {
|
|
target_id: 1,
|
|
virtual_id,
|
|
provider_id: 1,
|
|
input_name: "input".intern(),
|
|
item_type: PlaylistItemType::LiveHls,
|
|
cluster: XtreamCluster::Live,
|
|
group: "group".intern(),
|
|
title: "title".intern(),
|
|
url: "http://localhost/stream.ts".intern(),
|
|
shared: false,
|
|
shared_joined_existing: None,
|
|
shared_stream_id: None,
|
|
technical: None,
|
|
epg_channel_id: None,
|
|
epg_reference_ts: None,
|
|
}
|
|
}
|
|
|
|
fn test_series_channel(virtual_id: u32) -> StreamChannel {
|
|
StreamChannel {
|
|
item_type: PlaylistItemType::Series,
|
|
cluster: XtreamCluster::Series,
|
|
url: "http://localhost/series/episode.mkv".intern(),
|
|
..test_channel(virtual_id)
|
|
}
|
|
}
|
|
|
|
/// Session refresh normalizes Expired -> Prepared.
|
|
/// When a new request arrives on an expired session, the lifecycle should be
|
|
/// reset to Prepared so that full activation evaluation happens.
|
|
#[tokio::test]
|
|
async fn create_user_session_normalizes_expired_lifecycle() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55400".parse().unwrap();
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-lifecycle-refresh".to_string();
|
|
|
|
// Create a session in Expired state directly via session manipulation
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let data = connections.by_key.entry(user.username.clone())
|
|
.or_insert_with(|| UserConnectionData::new(0, 1, 0));
|
|
data.add_session(UserSession {
|
|
token: "tok-refresh-expired".to_string(),
|
|
transition_version: 1,
|
|
virtual_id: 7001,
|
|
provider: "provider-a".intern(),
|
|
stream_url: "http://localhost/live.m3u8".intern(),
|
|
provider_session_headers: HashMap::new(),
|
|
addr,
|
|
socket_bound: false,
|
|
active_addrs: vec![addr],
|
|
ts: current_time_secs(),
|
|
started_at: current_time_secs(),
|
|
permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
lifecycle: PlaybackLifecycle::Expired,
|
|
});
|
|
}
|
|
|
|
// Refresh the session via create_user_session
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-refresh-expired",
|
|
virtual_id: 7001,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let sessions = manager.connections.read().await;
|
|
let data = sessions.by_key.get(&user.username).expect("user data should exist");
|
|
let session = data.sessions.iter().find(|s| s.token == "tok-refresh-expired").expect("session");
|
|
assert_eq!(
|
|
session.lifecycle,
|
|
PlaybackLifecycle::Prepared,
|
|
"Expired session should normalize to Prepared on refresh"
|
|
);
|
|
}
|
|
|
|
/// Session refresh does NOT normalize `PendingProvider`.
|
|
/// A `PendingProvider` session must not be reset — pending state must continue
|
|
/// until explicitly resolved via `activate_pending_provider` or `expire_pending_provider`.
|
|
#[tokio::test]
|
|
async fn create_user_session_does_not_normalize_pending_provider_lifecycle() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55401".parse().unwrap();
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-pending-lifecycle".to_string();
|
|
|
|
// Create a session in PendingProvider state
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let data = connections.by_key.entry(user.username.clone())
|
|
.or_insert_with(|| UserConnectionData::new(0, 1, 0));
|
|
data.add_session(UserSession {
|
|
token: "tok-refresh-pending".to_string(),
|
|
transition_version: 1,
|
|
virtual_id: 7002,
|
|
provider: "provider-a".intern(),
|
|
stream_url: "http://localhost/live.m3u8".intern(),
|
|
provider_session_headers: HashMap::new(),
|
|
addr,
|
|
socket_bound: false,
|
|
active_addrs: vec![addr],
|
|
ts: current_time_secs(),
|
|
started_at: current_time_secs(),
|
|
permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
lifecycle: PlaybackLifecycle::PendingProvider {
|
|
data: PendingProviderState {
|
|
reason_code: PendingProviderReason::GraceHold,
|
|
created_at: current_time_secs(),
|
|
deadline: current_time_secs() + 30,
|
|
version: 1,
|
|
wake_source: None,
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
// Refresh the session via create_user_session
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-refresh-pending",
|
|
virtual_id: 7002,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let sessions = manager.connections.read().await;
|
|
let data = sessions.by_key.get(&user.username).expect("user data should exist");
|
|
let session = data.sessions.iter().find(|s| s.token == "tok-refresh-pending").expect("session");
|
|
assert!(
|
|
matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { .. }),
|
|
"PendingProvider session should NOT be normalized on refresh - pending wait must continue"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_session_provider_headers_updates_existing_session_and_timestamp() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55402".parse().unwrap_or_else(|_| unreachable!());
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-provider-headers".to_string();
|
|
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-provider-headers",
|
|
virtual_id: 7003,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let before = manager
|
|
.get_and_update_user_session(&user.username, "tok-provider-headers")
|
|
.await
|
|
.expect("session should exist");
|
|
let previous_ts = before.ts;
|
|
let headers = HashMap::from([(String::from("cookie"), String::from("sid=abc"))]);
|
|
|
|
assert!(manager
|
|
.update_session_provider_headers(&user.username, "tok-provider-headers", &headers)
|
|
.await);
|
|
|
|
let after = manager
|
|
.get_and_update_user_session(&user.username, "tok-provider-headers")
|
|
.await
|
|
.expect("session should exist");
|
|
assert_eq!(after.provider_session_headers, headers);
|
|
assert!(after.ts >= previous_ts);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_session_provider_headers_returns_false_for_missing_user_or_token() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
let headers = HashMap::from([(String::from("cookie"), String::from("sid=abc"))]);
|
|
|
|
assert!(!manager
|
|
.update_session_provider_headers("missing-user", "missing-token", &headers)
|
|
.await);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55403".parse().unwrap_or_else(|_| unreachable!());
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-missing-token".to_string();
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-existing",
|
|
virtual_id: 7004,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
assert!(!manager
|
|
.update_session_provider_headers(&user.username, "tok-missing", &headers)
|
|
.await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_user_session_clears_provider_headers_when_provider_or_stream_url_changes() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55404".parse().unwrap_or_else(|_| unreachable!());
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-provider-header-reset".to_string();
|
|
let headers = HashMap::from([(String::from("cookie"), String::from("sid=abc"))]);
|
|
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-reset",
|
|
virtual_id: 7005,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-a.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
assert!(manager
|
|
.update_session_provider_headers(&user.username, "tok-reset", &headers)
|
|
.await);
|
|
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-reset",
|
|
virtual_id: 7005,
|
|
provider: "provider-b",
|
|
stream_url: "http://localhost/live-b.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let session = manager
|
|
.get_and_update_user_session(&user.username, "tok-reset")
|
|
.await
|
|
.expect("session should exist");
|
|
assert!(session.provider_session_headers.is_empty());
|
|
}
|
|
|
|
/// `terminate_session` expires a session and removes it.
|
|
#[tokio::test]
|
|
async fn terminate_session_expires_and_removes_session() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55410".parse().unwrap();
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-terminate".to_string();
|
|
user.max_connections = 2;
|
|
|
|
let token = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-terminate-test",
|
|
virtual_id: 8001,
|
|
provider: "provider-terminate",
|
|
stream_url: "http://localhost/test.ts",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
// Verify session exists.
|
|
let before = manager
|
|
.get_and_update_user_session(&user.username, &token)
|
|
.await;
|
|
assert!(before.is_some(), "session should exist before terminate");
|
|
assert_eq!(before.as_ref().unwrap().lifecycle, PlaybackLifecycle::Prepared);
|
|
|
|
// Terminate the session.
|
|
manager.terminate_session(&user.username, &token).await;
|
|
|
|
// Session should be gone.
|
|
let after = manager
|
|
.get_and_update_user_session(&user.username, &token)
|
|
.await;
|
|
assert!(after.is_none(), "session should be removed after terminate");
|
|
}
|
|
|
|
/// `terminate_session` releases counted lease.
|
|
#[tokio::test]
|
|
async fn terminate_session_releases_counted_lease() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55411".parse().unwrap();
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-terminate-counted".to_string();
|
|
user.max_connections = 2;
|
|
|
|
let token = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-terminate-counted",
|
|
virtual_id: 8002,
|
|
provider: "provider-terminate-counted",
|
|
stream_url: "http://localhost/test.ts",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
// Mark the session as counted and active (simulating post-admission state).
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let data = connections.by_key.get_mut(&user.username).unwrap();
|
|
let session = data.sessions.iter_mut().find(|s| s.token == token).unwrap();
|
|
// Simulate counted state by setting lifecycle to Active.
|
|
session.lifecycle = PlaybackLifecycle::Active;
|
|
data.increment_kind(ConnectionKind::Normal);
|
|
}
|
|
|
|
// Verify counted before terminate.
|
|
{
|
|
let before = manager
|
|
.get_and_update_user_session(&user.username, &token)
|
|
.await
|
|
.unwrap();
|
|
assert!(before.lifecycle.is_counted(), "session should be counted before terminate");
|
|
}
|
|
|
|
// Terminate.
|
|
manager.terminate_session(&user.username, &token).await;
|
|
|
|
// Session should be gone.
|
|
let after = manager
|
|
.get_and_update_user_session(&user.username, &token)
|
|
.await;
|
|
assert!(after.is_none(), "session should be removed after terminate");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn terminate_session_removes_preserved_adaptive_stream() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55412".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-terminate-preserved".to_string(), "127.0.0.1".to_string(), addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-terminate-preserved".to_string();
|
|
user.max_connections = 1;
|
|
|
|
let token = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-terminate-preserved",
|
|
virtual_id: 8003,
|
|
provider: "provider-terminate-preserved",
|
|
stream_url: "http://localhost/test.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 8003,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: user.max_connections,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-terminate-preserved".intern(),
|
|
stream_channel: &test_adaptive_channel(8003),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some(&token),
|
|
})
|
|
.await
|
|
.expect("adaptive stream should be registered");
|
|
|
|
let released = manager.release_connection(&addr).await;
|
|
assert!(released.addr_removed);
|
|
assert!(released.removed_streams.is_empty(), "adaptive stream should be preserved first");
|
|
|
|
manager.terminate_session(&user.username, &token).await;
|
|
|
|
let connections = manager.connections.read().await;
|
|
let connection_data = connections
|
|
.by_key
|
|
.get(&user.username)
|
|
.expect("user data should remain inspectable");
|
|
assert!(
|
|
connection_data.streams.is_empty(),
|
|
"terminating a session must remove its preserved adaptive stream"
|
|
);
|
|
assert!(connection_data.sessions.iter().all(|session| session.token != token));
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
#[tokio::test]
|
|
async fn terminate_session_promotes_soft_stream_after_releasing_capacity() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let normal_addr: SocketAddr = "127.0.0.1:55413".parse().unwrap();
|
|
let soft_addr: SocketAddr = "127.0.0.1:55414".parse().unwrap();
|
|
let soft_addr_two: SocketAddr = "127.0.0.1:55415".parse().unwrap();
|
|
let normal_fp = Fingerprint::new("fp-terminate-normal".to_string(), "127.0.0.1".to_string(), normal_addr);
|
|
let soft_fp = Fingerprint::new("fp-terminate-soft".to_string(), "127.0.0.1".to_string(), soft_addr);
|
|
let soft_fp_two = Fingerprint::new("fp-terminate-soft-2".to_string(), "127.0.0.1".to_string(), soft_addr_two);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-terminate-promote".to_string();
|
|
user.max_connections = 1;
|
|
user.soft_connections = 2;
|
|
|
|
manager.add_connection(&normal_addr).await;
|
|
manager.add_connection(&soft_addr).await;
|
|
manager.add_connection(&soft_addr_two).await;
|
|
|
|
let normal_token = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-terminate-normal",
|
|
virtual_id: 8101,
|
|
provider: "provider-normal",
|
|
stream_url: "http://localhost/normal.ts",
|
|
addr: &normal_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
let soft_token = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-terminate-soft",
|
|
virtual_id: 8102,
|
|
provider: "provider-soft",
|
|
stream_url: "http://localhost/soft.ts",
|
|
addr: &soft_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Soft),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
let soft_token_two = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-terminate-soft-2",
|
|
virtual_id: 8103,
|
|
provider: "provider-soft-2",
|
|
stream_url: "http://localhost/soft-2.ts",
|
|
addr: &soft_addr_two,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Soft),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 8101,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: user.max_connections,
|
|
soft_connections: user.soft_connections,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &normal_fp,
|
|
provider: "provider-normal".intern(),
|
|
stream_channel: &test_channel(8101),
|
|
user_agent: Cow::Borrowed("ua-normal"),
|
|
session_token: Some(&normal_token),
|
|
})
|
|
.await
|
|
.expect("normal stream should be registered");
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 8102,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: user.max_connections,
|
|
soft_connections: user.soft_connections,
|
|
connection_kind: ConnectionKind::Soft,
|
|
priority: -5,
|
|
soft_priority: 9,
|
|
fingerprint: &soft_fp,
|
|
provider: "provider-soft".intern(),
|
|
stream_channel: &test_channel(8102),
|
|
user_agent: Cow::Borrowed("ua-soft"),
|
|
session_token: Some(&soft_token),
|
|
})
|
|
.await
|
|
.expect("soft stream should be registered");
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 8103,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: user.max_connections,
|
|
soft_connections: user.soft_connections,
|
|
connection_kind: ConnectionKind::Soft,
|
|
priority: -3,
|
|
soft_priority: 9,
|
|
fingerprint: &soft_fp_two,
|
|
provider: "provider-soft-2".intern(),
|
|
stream_channel: &test_channel(8103),
|
|
user_agent: Cow::Borrowed("ua-soft-2"),
|
|
session_token: Some(&soft_token_two),
|
|
})
|
|
.await
|
|
.expect("second soft stream should be registered");
|
|
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let connection_data = connections
|
|
.by_key
|
|
.get_mut(&user.username)
|
|
.expect("user data should exist");
|
|
connection_data.soft_connections = 1;
|
|
}
|
|
|
|
manager.terminate_session(&user.username, &normal_token).await;
|
|
|
|
let connections = manager.connections.read().await;
|
|
let connection_data = connections
|
|
.by_key
|
|
.get(&user.username)
|
|
.expect("user data should remain inspectable");
|
|
assert_eq!(connection_data.counts.normal, 1);
|
|
assert_eq!(connection_data.counts.soft, 1);
|
|
let promoted_uid = [8102_u32, 8103_u32]
|
|
.into_iter()
|
|
.find(|uid| connection_data.stream_kinds.get(uid) == Some(&ConnectionKind::Normal))
|
|
.expect("one soft stream should be promoted to normal");
|
|
let promoted_token = if promoted_uid == 8102 {
|
|
soft_token.as_str()
|
|
} else {
|
|
soft_token_two.as_str()
|
|
};
|
|
let promoted_session = connection_data
|
|
.sessions
|
|
.iter()
|
|
.find(|session| session.token == promoted_token)
|
|
.expect("promoted soft session should remain");
|
|
assert_eq!(promoted_session.connection_kind, Some(ConnectionKind::Normal));
|
|
assert!(matches!(promoted_session.lifecycle, PlaybackLifecycle::Active));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_multi_session_same_addr_counts_and_releases_individually() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55001".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-key".to_string(), "127.0.0.1".to_string(), addr);
|
|
let username = "user1";
|
|
|
|
manager.add_connection(&addr).await;
|
|
|
|
let first = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 1,
|
|
meter_uid: 0,
|
|
username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(1001),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-1"),
|
|
})
|
|
.await;
|
|
assert!(first.is_some());
|
|
assert_eq!(manager.user_connections(username).await, 1);
|
|
assert_eq!(
|
|
manager.connection_permission(username, 1, 0).await,
|
|
UserConnectionPermission::Exhausted
|
|
);
|
|
|
|
let second = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 2,
|
|
meter_uid: 0,
|
|
username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-b".intern(),
|
|
stream_channel: &test_channel(1002),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-2"),
|
|
})
|
|
.await;
|
|
assert!(second.is_some());
|
|
assert_eq!(manager.user_connections(username).await, 2);
|
|
|
|
assert!(manager.release_stream(&addr).await.is_some());
|
|
assert_eq!(manager.user_connections(username).await, 1);
|
|
|
|
assert!(manager.release_stream(&addr).await.is_some());
|
|
assert_eq!(manager.user_connections(username).await, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mark_pending_provider_tracks_metadata_on_session() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55021".parse().unwrap_or_else(|_| unreachable!());
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "pending-user".to_string();
|
|
|
|
let _ = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-pending",
|
|
virtual_id: 1001,
|
|
provider: "provider-a",
|
|
stream_url: "http://provider/live/1001.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let _ = manager
|
|
.mark_pending_provider(&user.username, "tok-pending", PendingProviderReason::GraceHold, 12_345)
|
|
.await;
|
|
|
|
let session = manager
|
|
.get_and_update_user_session(&user.username, "tok-pending")
|
|
.await
|
|
.expect("session should exist");
|
|
let PlaybackLifecycle::PendingProvider { data: pending } = &session.lifecycle else {
|
|
panic!("pending provider should be tracked")
|
|
};
|
|
assert!(matches!(pending.reason_code, PendingProviderReason::GraceHold));
|
|
assert_eq!(pending.deadline, 12_345);
|
|
assert!(pending.created_at > 0);
|
|
assert_eq!(pending.version, 1);
|
|
assert!(pending.wake_source.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn activate_pending_provider_clears_pending_metadata() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55022".parse().unwrap_or_else(|_| unreachable!());
|
|
let fingerprint = Fingerprint::new("fp-pending".to_string(), "127.0.0.1".to_string(), addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "pending-activate".to_string();
|
|
|
|
let _ = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-pending-activate",
|
|
virtual_id: 1002,
|
|
provider: "provider-a",
|
|
stream_url: "http://provider/live/1002.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
let _ = manager
|
|
.mark_pending_provider(
|
|
&user.username,
|
|
"tok-pending-activate",
|
|
PendingProviderReason::GraceHold,
|
|
current_time_secs().saturating_add(30),
|
|
)
|
|
.await;
|
|
|
|
let _ = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 12,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(1002),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-pending-activate"),
|
|
})
|
|
.await;
|
|
manager
|
|
.activate_pending_provider(
|
|
&user.username,
|
|
"tok-pending-activate",
|
|
1,
|
|
PendingProviderWakeSource::Activated,
|
|
)
|
|
.await;
|
|
|
|
let session = manager
|
|
.get_and_update_user_session(&user.username, "tok-pending-activate")
|
|
.await
|
|
.expect("session should exist");
|
|
assert!(session.lifecycle.is_counted());
|
|
assert!(!matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { .. }), "explicit pending resolution must clear pending provider state");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn activate_pending_provider_ignores_stale_version_after_replacement() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55023".parse().unwrap_or_else(|_| unreachable!());
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "pending-stale".to_string();
|
|
|
|
let _ = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-pending-stale",
|
|
virtual_id: 1003,
|
|
provider: "provider-a",
|
|
stream_url: "http://provider/live/1003.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let first_version = manager
|
|
.mark_pending_provider(
|
|
&user.username,
|
|
"tok-pending-stale",
|
|
PendingProviderReason::GraceHold,
|
|
5_000,
|
|
)
|
|
.await
|
|
.expect("first pending version should be created");
|
|
let second_version = manager
|
|
.mark_pending_provider(
|
|
&user.username,
|
|
"tok-pending-stale",
|
|
PendingProviderReason::GraceHold,
|
|
6_000,
|
|
)
|
|
.await
|
|
.expect("second pending version should replace the first");
|
|
assert!(second_version > first_version);
|
|
|
|
manager
|
|
.activate_pending_provider(
|
|
&user.username,
|
|
"tok-pending-stale",
|
|
first_version,
|
|
PendingProviderWakeSource::CapacityNotify,
|
|
)
|
|
.await;
|
|
|
|
let session = manager
|
|
.get_and_update_user_session(&user.username, "tok-pending-stale")
|
|
.await
|
|
.expect("session should still exist");
|
|
let PlaybackLifecycle::PendingProvider { data: pending_data } = &session.lifecycle else {
|
|
panic!("session should still be in PendingProvider after stale wakeup")
|
|
};
|
|
assert_eq!(pending_data.version, second_version);
|
|
assert!(pending_data.wake_source.is_none());
|
|
assert_eq!(session.permission, UserConnectionPermission::GracePeriod);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn expire_pending_provider_marks_session_exhausted() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55024".parse().unwrap_or_else(|_| unreachable!());
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "pending-expire".to_string();
|
|
|
|
let _ = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-pending-expire",
|
|
virtual_id: 1004,
|
|
provider: "provider-a",
|
|
stream_url: "http://provider/live/1004.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let version = manager
|
|
.mark_pending_provider(
|
|
&user.username,
|
|
"tok-pending-expire",
|
|
PendingProviderReason::GraceHold,
|
|
6_000,
|
|
)
|
|
.await
|
|
.expect("pending version should be created");
|
|
|
|
manager
|
|
.expire_pending_provider(
|
|
&user.username,
|
|
"tok-pending-expire",
|
|
version,
|
|
PendingProviderWakeSource::Timeout,
|
|
)
|
|
.await;
|
|
|
|
let session = manager
|
|
.get_and_update_user_session(&user.username, "tok-pending-expire")
|
|
.await
|
|
.expect("session should still exist");
|
|
assert_eq!(session.permission, UserConnectionPermission::Exhausted);
|
|
assert!(!matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { .. }));
|
|
assert!(!session.lifecycle.is_counted());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn expire_pending_provider_releases_counted_slot_for_pending_session() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55025".parse().unwrap_or_else(|_| unreachable!());
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "pending-expire-counted".to_string();
|
|
user.max_connections = 1;
|
|
|
|
let _ = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-pending-expire-counted",
|
|
virtual_id: 1005,
|
|
provider: "provider-a",
|
|
stream_url: "http://provider/live/1005.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let connection_data = connections
|
|
.by_key
|
|
.get_mut(&user.username)
|
|
.expect("session should have created connection data");
|
|
connection_data.increment_kind(ConnectionKind::Normal);
|
|
let session = connection_data
|
|
.sessions
|
|
.iter_mut()
|
|
.find(|session| session.token == "tok-pending-expire-counted")
|
|
.expect("session should exist");
|
|
// Simulate a previously-counted session transitioning to PendingProvider.
|
|
// Set lifecycle to Active (is_counted() = true). The kind count is already
|
|
// incremented above via connection_data.increment_kind().
|
|
session.lifecycle = PlaybackLifecycle::Active;
|
|
}
|
|
|
|
assert_eq!(manager.user_connections(&user.username).await, 1);
|
|
|
|
let version = manager
|
|
.mark_pending_provider(
|
|
&user.username,
|
|
"tok-pending-expire-counted",
|
|
PendingProviderReason::GraceHold,
|
|
6_500,
|
|
)
|
|
.await
|
|
.expect("pending version should be created");
|
|
|
|
manager
|
|
.expire_pending_provider(
|
|
&user.username,
|
|
"tok-pending-expire-counted",
|
|
version,
|
|
PendingProviderWakeSource::Timeout,
|
|
)
|
|
.await;
|
|
|
|
let session = manager
|
|
.get_and_update_user_session(&user.username, "tok-pending-expire-counted")
|
|
.await
|
|
.expect("session should still exist");
|
|
assert_eq!(session.permission, UserConnectionPermission::Exhausted);
|
|
assert!(!matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { .. }));
|
|
assert!(!session.lifecycle.is_counted());
|
|
assert_eq!(manager.user_connections(&user.username).await, 0);
|
|
}
|
|
|
|
/// `terminate_sessions_for_addr` expires all sessions at a given addr and releases counted leases.
|
|
#[tokio::test]
|
|
async fn terminate_sessions_for_addr_expires_all_sessions_at_addr() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr_kick: SocketAddr = "127.0.0.1:55420".parse().unwrap();
|
|
let addr_keep: SocketAddr = "127.0.0.1:55421".parse().unwrap();
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-kick-addr".to_string();
|
|
user.max_connections = 4;
|
|
|
|
// Create session at kicked addr.
|
|
let tok_kick = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-kick",
|
|
virtual_id: 1,
|
|
provider: "provider-a",
|
|
stream_url: "http://provider/live/1.m3u8",
|
|
addr: &addr_kick,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
// Create session at kept addr.
|
|
let tok_keep = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-keep",
|
|
virtual_id: 2,
|
|
provider: "provider-b",
|
|
stream_url: "http://provider/live/2.m3u8",
|
|
addr: &addr_keep,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
// Mark both sessions as counted and active.
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let data = connections.by_key.get_mut(&user.username).unwrap();
|
|
for session in &mut data.sessions {
|
|
// Simulate counted state by setting lifecycle to Active.
|
|
session.lifecycle = PlaybackLifecycle::Active;
|
|
}
|
|
data.increment_kind(ConnectionKind::Normal);
|
|
data.increment_kind(ConnectionKind::Normal);
|
|
}
|
|
|
|
assert_eq!(manager.user_connections(&user.username).await, 2);
|
|
|
|
// Kick the addr — should terminate only the sessions at that addr.
|
|
manager.terminate_sessions_for_addr(&user.username, &addr_kick).await;
|
|
|
|
// Session at kicked addr should be gone.
|
|
assert!(
|
|
manager.get_and_update_user_session(&user.username, &tok_kick).await.is_none(),
|
|
"kicked session should be removed"
|
|
);
|
|
|
|
// Session at kept addr should remain.
|
|
let kept = manager
|
|
.get_and_update_user_session(&user.username, &tok_keep)
|
|
.await
|
|
.expect("kept session should still exist");
|
|
assert_eq!(kept.token, tok_keep);
|
|
assert_eq!(kept.addr, addr_keep);
|
|
|
|
// Connection count should drop by 1.
|
|
assert_eq!(manager.user_connections(&user.username).await, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_grant_grace_succeeds_at_and_above_limit_without_prior_grace() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let at_limit_addr: SocketAddr = "127.0.0.1:55011".parse().unwrap();
|
|
let at_limit_fingerprint = Fingerprint::new("fp-limit".to_string(), "127.0.0.1".to_string(), at_limit_addr);
|
|
let over_limit_addr: SocketAddr = "127.0.0.1:55012".parse().unwrap();
|
|
let over_limit_fingerprint = Fingerprint::new("fp-over".to_string(), "127.0.0.1".to_string(), over_limit_addr);
|
|
|
|
manager.add_connection(&at_limit_addr).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 10,
|
|
meter_uid: 0,
|
|
username: "at-limit",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &at_limit_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(1010),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-limit"),
|
|
})
|
|
.await;
|
|
|
|
assert!(manager.grant_grace("at-limit").await);
|
|
|
|
manager.add_connection(&over_limit_addr).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 11,
|
|
meter_uid: 0,
|
|
username: "over-limit",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &over_limit_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(1011),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-over-1"),
|
|
})
|
|
.await;
|
|
manager.add_connection(&"127.0.0.1:55013".parse().unwrap()).await;
|
|
let second_fingerprint =
|
|
Fingerprint::new("fp-over-2".to_string(), "127.0.0.1".to_string(), "127.0.0.1:55013".parse().unwrap());
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 12,
|
|
meter_uid: 0,
|
|
username: "over-limit",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &second_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(1012),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-over-2"),
|
|
})
|
|
.await;
|
|
|
|
assert!(manager.grant_grace("over-limit").await);
|
|
}
|
|
|
|
fn test_user_credentials(username: &str, max_connections: u32, soft_connections: u16) -> ProxyUserCredentials {
|
|
ProxyUserCredentials {
|
|
username: username.to_string(),
|
|
password: "test".to_string(),
|
|
token: None,
|
|
proxy: ProxyType::default(),
|
|
server: None,
|
|
epg_timeshift: None,
|
|
epg_request_timeshift: None,
|
|
created_at: None,
|
|
exp_date: None,
|
|
max_connections,
|
|
status: None,
|
|
output_clusters: shared::model::ClusterFlags::all(),
|
|
ui_enabled: true,
|
|
comment: None,
|
|
priority: 0,
|
|
soft_connections,
|
|
soft_priority: 0,
|
|
t_is_api_user: false,
|
|
network_access: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn eviction_candidates_ignore_ambiguous_socket_addrs() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let shared_addr: SocketAddr = "127.0.0.1:55031".parse().unwrap();
|
|
let unique_addr: SocketAddr = "127.0.0.1:55032".parse().unwrap();
|
|
let shared_fp = Fingerprint::new("fp-shared".to_string(), "127.0.0.1".to_string(), shared_addr);
|
|
let unique_fp = Fingerprint::new("fp-unique".to_string(), "127.0.0.1".to_string(), unique_addr);
|
|
|
|
manager.add_connection(&shared_addr).await;
|
|
manager.add_connection(&unique_addr).await;
|
|
|
|
// Create sessions first so update_connection can mark them as counted.
|
|
let user = test_user_credentials("same-user", 3, 0);
|
|
for (token, addr, channel_id) in [
|
|
("tok-31", shared_addr, 1031u32),
|
|
("tok-32", shared_addr, 1032),
|
|
("tok-33", unique_addr, 1033),
|
|
] {
|
|
manager.create_user_session(crate::api::model::CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: token,
|
|
virtual_id: channel_id,
|
|
provider: "provider-a",
|
|
stream_url: "",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
}).await;
|
|
}
|
|
|
|
// update_connection marks the session as counted.
|
|
for (uid, token, fp, channel_id) in [(31, "tok-31", &shared_fp, 1031u32), (32, "tok-32", &shared_fp, 1032)] {
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid,
|
|
meter_uid: 0,
|
|
username: "same-user",
|
|
max_connections: 3,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: fp,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(channel_id),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some(token),
|
|
})
|
|
.await;
|
|
}
|
|
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 33,
|
|
meter_uid: 0,
|
|
username: "same-user",
|
|
max_connections: 3,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &unique_fp,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(1033),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-33"),
|
|
})
|
|
.await;
|
|
|
|
let candidates = manager.get_eviction_candidates("same-user", "127.0.0.1").await;
|
|
assert_eq!(candidates.len(), 1);
|
|
assert_eq!(candidates[0].addr, unique_addr);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn eviction_candidates_include_other_ips_for_user_wide_rules() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let first_addr: SocketAddr = "127.0.0.1:55041".parse().unwrap();
|
|
let second_addr: SocketAddr = "127.0.0.1:55042".parse().unwrap();
|
|
let first_fp = Fingerprint::new("fp-user-wide-1".to_string(), "10.0.0.1".to_string(), first_addr);
|
|
let second_fp = Fingerprint::new("fp-user-wide-2".to_string(), "10.0.0.2".to_string(), second_addr);
|
|
|
|
manager.add_connection(&first_addr).await;
|
|
manager.add_connection(&second_addr).await;
|
|
|
|
let user = test_user_credentials("same-user", 2, 0);
|
|
for (token, addr, channel_id) in [
|
|
("tok-41", first_addr, 1041u32),
|
|
("tok-42", second_addr, 1042),
|
|
] {
|
|
manager.create_user_session(crate::api::model::CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: token,
|
|
virtual_id: channel_id,
|
|
provider: "provider-a",
|
|
stream_url: "",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
}).await;
|
|
}
|
|
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 41,
|
|
meter_uid: 0,
|
|
username: "same-user",
|
|
max_connections: 2,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &first_fp,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(1041),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-41"),
|
|
})
|
|
.await;
|
|
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 42,
|
|
meter_uid: 0,
|
|
username: "same-user",
|
|
max_connections: 2,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &second_fp,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(1042),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-42"),
|
|
})
|
|
.await;
|
|
|
|
let candidates = manager.get_eviction_candidates("same-user", "10.0.0.1").await;
|
|
assert_eq!(candidates.len(), 2);
|
|
assert!(candidates.iter().any(|candidate| candidate.addr == first_addr));
|
|
assert!(candidates.iter().any(|candidate| candidate.addr == second_addr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn eviction_candidates_include_preserved_adaptive_streams() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55043".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-preserved".to_string(), "127.0.0.1".to_string(), addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("same-user");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-preserved",
|
|
virtual_id: 1043,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 43,
|
|
meter_uid: 0,
|
|
username: "same-user",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveHls,
|
|
..test_channel(1043)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-preserved"),
|
|
})
|
|
.await;
|
|
|
|
let released = manager.release_connection(&addr).await;
|
|
assert!(released.addr_removed);
|
|
assert!(released.removed_streams.is_empty(), "adaptive stream should stay logically active");
|
|
|
|
let candidates = manager.get_eviction_candidates("same-user", "127.0.0.1").await;
|
|
assert_eq!(candidates.len(), 1, "preserved adaptive streams must remain evictable");
|
|
assert_eq!(candidates[0].addr, addr);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_kicked_release_does_not_preserve_adaptive_stream() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55014".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-adaptive".to_string(), "127.0.0.1".to_string(), addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user-adaptive");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-adaptive",
|
|
virtual_id: 2014,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 14,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(2014),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-adaptive"),
|
|
})
|
|
.await;
|
|
|
|
let removed = manager.release_connection_as_kicked(&addr).await;
|
|
assert!(removed.addr_removed);
|
|
assert_eq!(removed.removed_streams.len(), 1);
|
|
assert!(manager.active_streams().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn kicked_release_removes_preserved_adaptive_stream_without_socket_registration() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55017".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-preserved-kick".to_string(), "127.0.0.1".to_string(), addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user-preserved-kick");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-preserved-kick",
|
|
virtual_id: 2017,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 17,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(2017),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-preserved-kick"),
|
|
})
|
|
.await;
|
|
|
|
let released = manager.release_connection(&addr).await;
|
|
assert!(released.addr_removed);
|
|
assert!(released.removed_streams.is_empty());
|
|
assert!(manager.active_streams().await.is_empty());
|
|
|
|
let kicked = manager.release_connection_as_kicked(&addr).await;
|
|
assert!(kicked.addr_removed);
|
|
assert_eq!(kicked.removed_streams.len(), 1);
|
|
assert!(manager.active_streams().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_kicked_release_invalidates_removed_session_tokens() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let kicked_addr: SocketAddr = "127.0.0.1:55015".parse().unwrap();
|
|
let survivor_addr: SocketAddr = "127.0.0.1:55016".parse().unwrap();
|
|
let kicked_fingerprint = Fingerprint::new("fp-kicked".to_string(), "127.0.0.1".to_string(), kicked_addr);
|
|
let survivor_fingerprint = Fingerprint::new("fp-survivor".to_string(), "127.0.0.1".to_string(), survivor_addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("kicked-user");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&kicked_addr).await;
|
|
manager.add_connection(&survivor_addr).await;
|
|
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-kicked",
|
|
virtual_id: 2015,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-1.ts",
|
|
addr: &kicked_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: true,
|
|
})
|
|
.await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-survivor",
|
|
virtual_id: 2016,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-2.ts",
|
|
addr: &survivor_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: true,
|
|
})
|
|
.await;
|
|
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 15,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &kicked_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(2015),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-kicked"),
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 16,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &survivor_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(2016),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-survivor"),
|
|
})
|
|
.await;
|
|
|
|
let removed = manager.release_connection_as_kicked(&kicked_addr).await;
|
|
assert!(removed.addr_removed);
|
|
assert_eq!(removed.removed_streams.len(), 1);
|
|
assert_eq!(
|
|
manager
|
|
.connection_admission_for_session(&user.username, 1, 0, "tok-kicked")
|
|
.await
|
|
.permission,
|
|
UserConnectionPermission::Exhausted
|
|
);
|
|
assert_eq!(
|
|
manager
|
|
.connection_admission_for_session(&user.username, 1, 0, "tok-survivor")
|
|
.await
|
|
.permission,
|
|
UserConnectionPermission::Allowed
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_grace_at_limit_remains_active_until_connections_drop_below_limit() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55017".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-grace".to_string(), "127.0.0.1".to_string(), addr);
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 17,
|
|
meter_uid: 0,
|
|
username: "grace-at-limit",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(2017),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-grace"),
|
|
})
|
|
.await;
|
|
|
|
assert!(manager.grant_grace("grace-at-limit").await);
|
|
assert_eq!(
|
|
manager.connection_admission("grace-at-limit", 1, 0).await.permission,
|
|
UserConnectionPermission::Exhausted
|
|
);
|
|
assert!(!manager.grant_grace("grace-at-limit").await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_same_session_token_on_new_addr_reuses_logical_connection() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let first_addr: SocketAddr = "127.0.0.1:55021".parse().unwrap();
|
|
let second_addr: SocketAddr = "127.0.0.1:55022".parse().unwrap();
|
|
let first = Fingerprint::new("fp-key-1".to_string(), "127.0.0.1".to_string(), first_addr);
|
|
let second = Fingerprint::new("fp-key-2".to_string(), "127.0.0.1".to_string(), second_addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&first_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-hls",
|
|
virtual_id: 2001,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &first_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 0,
|
|
meter_uid: 0,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &first,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(2001),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-hls"),
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(
|
|
manager.connection_permission_for_session("user1", 1, 0, "tok-hls").await,
|
|
UserConnectionPermission::Allowed
|
|
);
|
|
|
|
manager.add_connection(&second_addr).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 0,
|
|
meter_uid: 0,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &second,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(2001),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-hls"),
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(manager.user_connections("user1").await, 1);
|
|
|
|
let streams = manager.active_streams().await;
|
|
assert_eq!(streams.len(), 1);
|
|
assert_eq!(streams[0].addr, second_addr);
|
|
assert_eq!(streams[0].session_token.as_deref(), Some("tok-hls"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn adaptive_session_stream_cleanup_addrs_excludes_manifest_addr_and_current_addr() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let manifest_addr: SocketAddr = "127.0.0.1:55091".parse().unwrap();
|
|
let first_segment_addr: SocketAddr = "10.41.41.89:55092".parse().unwrap();
|
|
let next_segment_addr: SocketAddr = "10.41.41.89:55093".parse().unwrap();
|
|
let first_segment = Fingerprint::new("fp-segment-1".to_string(), "10.41.41.89".to_string(), first_segment_addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&manifest_addr).await;
|
|
manager.add_connection(&first_segment_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-hls-cleanup",
|
|
virtual_id: 2002,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &manifest_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 0,
|
|
meter_uid: 0,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &first_segment,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveHls,
|
|
..test_channel(2002)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-hls-cleanup"),
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(
|
|
manager
|
|
.adaptive_session_stream_cleanup_addrs("user1", "tok-hls-cleanup", &next_segment_addr)
|
|
.await,
|
|
vec![first_segment_addr]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn adaptive_session_stream_cleanup_addrs_falls_back_to_same_ip_session_addrs() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let manifest_addr: SocketAddr = "127.0.0.1:55101".parse().unwrap();
|
|
let first_segment_addr: SocketAddr = "10.41.41.89:55102".parse().unwrap();
|
|
let next_segment_addr: SocketAddr = "10.41.41.89:55103".parse().unwrap();
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user2");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&manifest_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-hls-cleanup-fallback",
|
|
virtual_id: 2003,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &manifest_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager.update_session_addr("user2", "tok-hls-cleanup-fallback", &first_segment_addr).await;
|
|
manager.update_session_addr("user2", "tok-hls-cleanup-fallback", &next_segment_addr).await;
|
|
|
|
assert_eq!(
|
|
manager
|
|
.adaptive_session_stream_cleanup_addrs("user2", "tok-hls-cleanup-fallback", &next_segment_addr)
|
|
.await,
|
|
vec![first_segment_addr]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recently_evicted_session_guard_survives_ttl_while_protected_addr_is_still_active() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let evicted_addr: SocketAddr = "127.0.0.1:55111".parse().unwrap();
|
|
let protected_addr: SocketAddr = "127.0.0.1:55112".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-guard-session".to_string(), "127.0.0.1".to_string(), evicted_addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("guard-user");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&evicted_addr).await;
|
|
manager.add_connection(&protected_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-guard-session",
|
|
virtual_id: 2018,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &evicted_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 18,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(2018),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-guard-session"),
|
|
})
|
|
.await;
|
|
|
|
manager
|
|
.mark_recent_eviction_guard_for_addr(&evicted_addr, protected_addr, 1)
|
|
.await;
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
if let Some(registration) = connections.key_by_addr.get_mut(&protected_addr) {
|
|
registration.username = user.username.clone();
|
|
}
|
|
let protection = connections
|
|
.recently_evicted_sessions
|
|
.get_mut("tok-guard-session")
|
|
.expect("recent eviction guard should exist");
|
|
protection.expires_at = current_time_secs().saturating_sub(1);
|
|
}
|
|
|
|
assert_eq!(
|
|
manager
|
|
.recently_evicted_session_protected_addr("tok-guard-session")
|
|
.await,
|
|
Some(protected_addr)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recently_evicted_vod_uses_session_reentry_guard() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let evicted_addr: SocketAddr = "127.0.0.1:55113".parse().unwrap();
|
|
let protected_addr: SocketAddr = "127.0.0.1:55114".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-vod-guard".to_string(), "127.0.0.1".to_string(), evicted_addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("vod-guard-user");
|
|
user.max_connections = 1;
|
|
let mut channel = test_channel(2019);
|
|
channel.item_type = PlaylistItemType::Video;
|
|
channel.cluster = XtreamCluster::Video;
|
|
channel.url = "http://localhost/movie.mkv".intern();
|
|
|
|
manager.add_connection(&evicted_addr).await;
|
|
manager.add_connection(&protected_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-guard-vod",
|
|
virtual_id: channel.virtual_id,
|
|
provider: "provider-a",
|
|
stream_url: channel.url.as_ref(),
|
|
addr: &evicted_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 19,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &channel,
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-guard-vod"),
|
|
})
|
|
.await;
|
|
|
|
manager
|
|
.mark_recent_eviction_guard_for_addr(&evicted_addr, protected_addr, 10)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
manager
|
|
.recently_evicted_session_protected_addr("tok-guard-vod")
|
|
.await,
|
|
Some(protected_addr)
|
|
);
|
|
let connections = manager.connections.read().await;
|
|
assert!(
|
|
connections.recent_socket_reentry_guards.is_empty(),
|
|
"provider-affine VOD must not be guarded by transient socket identity"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_affine_stream_without_session_token_uses_socket_reentry_fallback() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let evicted_addr: SocketAddr = "127.0.0.1:55115".parse().unwrap();
|
|
let protected_addr: SocketAddr = "127.0.0.1:55116".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-vod-no-token".to_string(), "127.0.0.1".to_string(), evicted_addr);
|
|
let mut channel = test_channel(2020);
|
|
channel.item_type = PlaylistItemType::Video;
|
|
channel.cluster = XtreamCluster::Video;
|
|
channel.url = "http://localhost/movie-no-token.mkv".intern();
|
|
|
|
manager.add_connection(&evicted_addr).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 20,
|
|
meter_uid: 0,
|
|
username: "vod-no-token-user",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &channel,
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: None,
|
|
})
|
|
.await;
|
|
|
|
manager
|
|
.mark_recent_eviction_guard_for_addr(&evicted_addr, protected_addr, 10)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
manager
|
|
.recent_socket_reentry_protected_addr("vod-no-token-user", "127.0.0.1", channel.virtual_id)
|
|
.await,
|
|
Some(protected_addr)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_reused_logical_stream_refreshes_normal_priority() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55023".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-key-2a".to_string(), "127.0.0.1".to_string(), addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-prio",
|
|
virtual_id: 2002,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-prio.ts",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Soft),
|
|
socket_bound: true,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 201,
|
|
meter_uid: 0,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 1,
|
|
connection_kind: ConnectionKind::Soft,
|
|
priority: 8,
|
|
soft_priority: 8,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(2002),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-prio"),
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 201,
|
|
meter_uid: 0,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 1,
|
|
connection_kind: ConnectionKind::Soft,
|
|
priority: -7,
|
|
soft_priority: 8,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(2002),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-prio"),
|
|
})
|
|
.await;
|
|
|
|
let connections = manager.connections.read().await;
|
|
let connection_data = connections.by_key.get("user1").unwrap();
|
|
assert_eq!(connection_data.stream_normal_priorities.get(&201), Some(&-7));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_same_session_token_refreshes_meter_metadata_on_reuse() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55031".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-key-3".to_string(), "127.0.0.1".to_string(), addr);
|
|
|
|
manager.add_connection(&addr).await;
|
|
let first = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 11,
|
|
meter_uid: 101,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(3001),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-meter"),
|
|
})
|
|
.await
|
|
.expect("initial stream should register");
|
|
assert_eq!(first.uid, 11);
|
|
assert_eq!(first.meter_uid, 101);
|
|
|
|
let second = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 22,
|
|
meter_uid: 202,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-b".intern(),
|
|
stream_channel: &test_adaptive_channel(3002),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-meter"),
|
|
})
|
|
.await
|
|
.expect("reused stream should register");
|
|
|
|
assert_eq!(second.uid, 11, "logical stream identity should stay stable on session reuse");
|
|
assert_eq!(second.meter_uid, 202, "reused stream must refresh its meter mapping");
|
|
|
|
let streams = manager.active_streams().await;
|
|
assert_eq!(streams.len(), 1);
|
|
assert_eq!(streams[0].uid, 11);
|
|
assert_eq!(streams[0].meter_uid, 202);
|
|
assert_eq!(streams[0].provider.as_ref(), "provider-b");
|
|
assert_eq!(streams[0].channel.virtual_id, 3002);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn socket_bound_live_streams_with_colliding_token_are_tracked_separately() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let Some(addr) = "127.0.0.1:55032".parse::<SocketAddr>().ok() else {
|
|
return;
|
|
};
|
|
let fingerprint = Fingerprint::new("fp-key-colliding".to_string(), "127.0.0.1".to_string(), addr);
|
|
|
|
manager.add_connection(&addr).await;
|
|
let first = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 31,
|
|
meter_uid: 301,
|
|
username: "user1",
|
|
max_connections: 0,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(3003),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-live-colliding"),
|
|
})
|
|
.await;
|
|
let second = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 32,
|
|
meter_uid: 302,
|
|
username: "user1",
|
|
max_connections: 0,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-b".intern(),
|
|
stream_channel: &test_channel(3003),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-live-colliding"),
|
|
})
|
|
.await;
|
|
|
|
assert!(first.is_some());
|
|
assert!(second.is_some());
|
|
|
|
let streams = manager.active_streams().await;
|
|
assert_eq!(streams.len(), 2);
|
|
assert!(streams.iter().any(|stream| stream.uid == 31));
|
|
assert!(streams.iter().any(|stream| stream.uid == 32));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unlimited_user_can_open_same_and_different_live_streams_from_same_ip() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let username = "unlimited-same-ip";
|
|
let client_ip = "10.9.0.1";
|
|
let addrs = [
|
|
"10.9.0.1:55101".parse::<SocketAddr>().unwrap(),
|
|
"10.9.0.1:55102".parse::<SocketAddr>().unwrap(),
|
|
"10.9.0.1:55103".parse::<SocketAddr>().unwrap(),
|
|
];
|
|
let fingerprints = [
|
|
Fingerprint::new("fp-unlimited-1".to_string(), client_ip.to_string(), addrs[0]),
|
|
Fingerprint::new("fp-unlimited-2".to_string(), client_ip.to_string(), addrs[1]),
|
|
Fingerprint::new("fp-unlimited-3".to_string(), client_ip.to_string(), addrs[2]),
|
|
];
|
|
|
|
for addr in addrs {
|
|
manager.add_connection(&addr).await;
|
|
}
|
|
|
|
for (idx, (fingerprint, virtual_id)) in fingerprints.iter().zip([4100, 4100, 4101]).enumerate() {
|
|
let token = format!("tok-unlimited-{idx}");
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 410 + u32::try_from(idx).unwrap_or_default(),
|
|
meter_uid: 0,
|
|
username,
|
|
max_connections: 0,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(virtual_id),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some(&token),
|
|
})
|
|
.await
|
|
.expect("unlimited stream should register");
|
|
}
|
|
|
|
assert_eq!(manager.user_connections(username).await, 3);
|
|
assert_eq!(manager.active_streams().await.len(), 3);
|
|
assert_eq!(
|
|
manager.connection_admission(username, 0, 0).await.permission,
|
|
UserConnectionPermission::Allowed
|
|
);
|
|
assert_eq!(
|
|
manager
|
|
.connection_admission_for_session(username, 0, 0, "tok-unlimited-new")
|
|
.await
|
|
.permission,
|
|
UserConnectionPermission::Allowed
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn release_stream_by_uid_removes_only_matching_stream_on_shared_addr() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let Some(addr) = "127.0.0.1:55033".parse::<SocketAddr>().ok() else {
|
|
return;
|
|
};
|
|
let fingerprint = Fingerprint::new("fp-key-shared-addr".to_string(), "127.0.0.1".to_string(), addr);
|
|
|
|
manager.add_connection(&addr).await;
|
|
for uid in [41, 42] {
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid,
|
|
meter_uid: uid + 300,
|
|
username: "user1",
|
|
max_connections: 0,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(3004),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-live-shared-addr"),
|
|
})
|
|
.await;
|
|
}
|
|
|
|
let removed = manager.release_stream_by_uid(&addr, 42).await;
|
|
assert!(removed.as_ref().is_some_and(|stream| stream.uid == 42));
|
|
|
|
let streams = manager.active_streams().await;
|
|
assert_eq!(streams.len(), 1);
|
|
assert_eq!(streams[0].uid, 41);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn release_stream_by_uid_finds_original_user_after_shared_addr_owner_changes() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55034".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-cross-user-stream".to_string(), "127.0.0.1".to_string(), addr);
|
|
manager.add_connection(&addr).await;
|
|
|
|
for (uid, username) in [(43, "user-a"), (44, "user-b")] {
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid,
|
|
meter_uid: 0,
|
|
username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_series_channel(3005),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: None,
|
|
})
|
|
.await
|
|
.expect("direct Series stream should register");
|
|
}
|
|
|
|
assert_eq!(manager.active_users_and_connections().await, (2, 2));
|
|
assert_eq!(manager.active_streams().await.len(), 2);
|
|
|
|
let removed = manager.release_stream_by_uid(&addr, 43).await;
|
|
assert!(removed.as_ref().is_some_and(|stream| stream.uid == 43));
|
|
assert_eq!(manager.active_users_and_connections().await, (1, 1));
|
|
let streams = manager.active_streams().await;
|
|
assert_eq!(streams.len(), 1);
|
|
assert_eq!(streams[0].uid, 44);
|
|
|
|
assert!(manager.release_stream_by_uid(&addr, 44).await.is_some());
|
|
assert_eq!(manager.active_users_and_connections().await, (0, 0));
|
|
assert!(manager.active_streams().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn release_connection_cleans_every_user_stream_for_reused_addr_only() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let reused_addr: SocketAddr = "127.0.0.1:55035".parse().unwrap();
|
|
let unrelated_addr: SocketAddr = "127.0.0.1:55036".parse().unwrap();
|
|
let reused_fingerprint =
|
|
Fingerprint::new("fp-cross-user-socket".to_string(), "127.0.0.1".to_string(), reused_addr);
|
|
let unrelated_fingerprint =
|
|
Fingerprint::new("fp-unrelated-socket".to_string(), "127.0.0.1".to_string(), unrelated_addr);
|
|
manager.add_connection(&reused_addr).await;
|
|
manager.add_connection(&unrelated_addr).await;
|
|
|
|
for (uid, username, fingerprint) in [
|
|
(45, "user-a", &reused_fingerprint),
|
|
(46, "user-b", &reused_fingerprint),
|
|
(47, "user-a", &unrelated_fingerprint),
|
|
] {
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid,
|
|
meter_uid: 0,
|
|
username,
|
|
max_connections: 2,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_series_channel(3006 + uid),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: None,
|
|
})
|
|
.await
|
|
.expect("direct Series stream should register");
|
|
}
|
|
|
|
let released = manager.release_connection(&reused_addr).await;
|
|
let mut removed_uids = released.removed_streams.iter().map(|stream| stream.uid).collect::<Vec<_>>();
|
|
removed_uids.sort_unstable();
|
|
assert!(released.addr_removed);
|
|
assert_eq!(removed_uids, vec![45, 46]);
|
|
assert_eq!(manager.active_users_and_connections().await, (1, 1));
|
|
let streams = manager.active_streams().await;
|
|
assert_eq!(streams.len(), 1);
|
|
assert_eq!(streams[0].uid, 47);
|
|
|
|
manager.release_connection(&unrelated_addr).await;
|
|
assert_eq!(manager.active_users_and_connections().await, (0, 0));
|
|
assert!(manager.active_streams().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connection_counts_are_broadcast_when_active_user_logging_is_disabled() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let mut events = event_manager.get_event_channel();
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55037".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-count-events".to_string(), "127.0.0.1".to_string(), addr);
|
|
manager.add_connection(&addr).await;
|
|
manager.release_connection(&addr).await;
|
|
assert!(
|
|
tokio::time::timeout(Duration::from_millis(50), events.recv()).await.is_err(),
|
|
"closing an unowned socket must not broadcast unchanged connection counts"
|
|
);
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 48,
|
|
meter_uid: 0,
|
|
username: "event-user",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_series_channel(3048),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: None,
|
|
})
|
|
.await
|
|
.expect("direct Series stream should register");
|
|
|
|
assert_eq!(
|
|
tokio::time::timeout(Duration::from_secs(1), events.recv())
|
|
.await
|
|
.expect("connection count update should be broadcast")
|
|
.expect("event channel should remain open"),
|
|
EventMessage::ActiveUser(ActiveUserConnectionChange::Connections(1, 1))
|
|
);
|
|
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 49,
|
|
meter_uid: 0,
|
|
username: "event-user",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_series_channel(3048),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: None,
|
|
})
|
|
.await
|
|
.expect("same direct Series stream should be reused");
|
|
assert!(
|
|
tokio::time::timeout(Duration::from_millis(50), events.recv()).await.is_err(),
|
|
"unchanged connection counts must not broadcast another full snapshot"
|
|
);
|
|
|
|
manager
|
|
.release_stream_by_uid(&addr, 48)
|
|
.await
|
|
.expect("direct Series stream should release");
|
|
assert_eq!(
|
|
tokio::time::timeout(Duration::from_secs(1), events.recv())
|
|
.await
|
|
.expect("released count update should be broadcast")
|
|
.expect("event channel should remain open"),
|
|
EventMessage::ActiveUser(ActiveUserConnectionChange::Connections(0, 0))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_adaptive_session_release_connection_preserves_logical_stream_and_start_time() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55041".parse().unwrap();
|
|
let next_addr: SocketAddr = "127.0.0.1:55042".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-key-4".to_string(), "127.0.0.1".to_string(), addr);
|
|
let next_fingerprint = Fingerprint::new("fp-key-5".to_string(), "127.0.0.1".to_string(), next_addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-hls",
|
|
virtual_id: 4001,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
let first = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 44,
|
|
meter_uid: 144,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveHls,
|
|
..test_channel(4001)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-hls"),
|
|
})
|
|
.await
|
|
.expect("initial adaptive session should register");
|
|
|
|
let released = manager.release_connection(&addr).await;
|
|
assert!(released.addr_removed);
|
|
assert!(released.removed_streams.is_empty(), "adaptive session should remain logically active");
|
|
assert_eq!(manager.user_connections("user1").await, 0);
|
|
assert_eq!(manager.active_users_and_connections().await, (0, 0));
|
|
assert!(manager.active_streams().await.is_empty());
|
|
|
|
let connections = manager.connections.read().await;
|
|
let preserved_stream = connections
|
|
.by_key
|
|
.get("user1")
|
|
.and_then(|data| data.streams.iter().find(|stream| stream.uid == 44))
|
|
.expect("preserved adaptive stream should stay internally tracked");
|
|
assert_eq!(preserved_stream.ts, first.ts);
|
|
assert!(preserved_stream.preserved);
|
|
drop(connections);
|
|
|
|
manager.add_connection(&next_addr).await;
|
|
let second = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 55,
|
|
meter_uid: 155,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &next_fingerprint,
|
|
provider: "provider-b".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveDash,
|
|
..test_channel(4002)
|
|
},
|
|
user_agent: Cow::Borrowed("ua-2"),
|
|
session_token: Some("tok-hls"),
|
|
})
|
|
.await
|
|
.expect("adaptive session should reuse logical stream");
|
|
|
|
assert_eq!(second.uid, 44);
|
|
assert_eq!(second.ts, first.ts, "adaptive session duration must stay session-based");
|
|
assert_eq!(second.addr, next_addr);
|
|
assert_eq!(second.meter_uid, 155);
|
|
assert_eq!(manager.user_connections("user1").await, 1);
|
|
|
|
let streams = manager.active_streams().await;
|
|
assert_eq!(streams.len(), 1);
|
|
assert!(!streams[0].preserved);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_release_stream_ignores_preserved_adaptive_entry() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55051".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-key-6".to_string(), "127.0.0.1".to_string(), addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-hls",
|
|
virtual_id: 5001,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 66,
|
|
meter_uid: 166,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveHls,
|
|
..test_channel(5001)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-hls"),
|
|
})
|
|
.await;
|
|
|
|
let released = manager.release_connection(&addr).await;
|
|
assert!(released.addr_removed);
|
|
assert!(released.removed_streams.is_empty());
|
|
assert!(manager.release_stream(&addr).await.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_preserved_adaptive_stream_is_pruned_after_session_ttl() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55061".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-key-7".to_string(), "127.0.0.1".to_string(), addr);
|
|
|
|
manager.add_connection(&addr).await;
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-expire",
|
|
virtual_id: 6001,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/hls.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 77,
|
|
meter_uid: 177,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveHls,
|
|
..test_channel(6001)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-expire"),
|
|
})
|
|
.await;
|
|
let released = manager.release_connection(&addr).await;
|
|
assert!(released.addr_removed);
|
|
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let connection_data = connections.by_key.get_mut("user1").unwrap();
|
|
let session = connection_data
|
|
.sessions
|
|
.iter_mut()
|
|
.find(|session| session.token == "tok-expire")
|
|
.unwrap();
|
|
session.ts = session.ts.saturating_sub(default_hls_session_ttl_secs() + 1);
|
|
}
|
|
if let Some(gc_ts) = &manager.gc_ts {
|
|
gc_ts.store(current_time_secs().saturating_sub(USER_GC_TTL + 1), Ordering::Release);
|
|
}
|
|
|
|
manager
|
|
.process_due_adaptive_expiry_entries(current_time_secs().saturating_add(default_hls_session_ttl_secs() + 1))
|
|
.await;
|
|
assert!(manager.active_streams().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_due_adaptive_expiry_removal_promotes_soft_stream() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let normal_addr: SocketAddr = "127.0.0.1:55062".parse().unwrap();
|
|
let soft_addr: SocketAddr = "127.0.0.1:55063".parse().unwrap();
|
|
let normal_fp = Fingerprint::new("fp-key-7a".to_string(), "127.0.0.1".to_string(), normal_addr);
|
|
let soft_fp = Fingerprint::new("fp-key-7b".to_string(), "127.0.0.1".to_string(), soft_addr);
|
|
|
|
manager.add_connection(&normal_addr).await;
|
|
manager.add_connection(&soft_addr).await;
|
|
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
user.soft_connections = 1;
|
|
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-expire-normal",
|
|
virtual_id: 6002,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/hls-normal.m3u8",
|
|
addr: &normal_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 78,
|
|
meter_uid: 178,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 1,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: -1,
|
|
soft_priority: 9,
|
|
fingerprint: &normal_fp,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveHls,
|
|
..test_channel(6002)
|
|
},
|
|
user_agent: Cow::Borrowed("ua-normal"),
|
|
session_token: Some("tok-expire-normal"),
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 79,
|
|
meter_uid: 179,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 1,
|
|
connection_kind: ConnectionKind::Soft,
|
|
priority: -5,
|
|
soft_priority: 9,
|
|
fingerprint: &soft_fp,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(6003),
|
|
user_agent: Cow::Borrowed("ua-soft"),
|
|
session_token: None,
|
|
})
|
|
.await;
|
|
|
|
let released = manager.release_connection(&normal_addr).await;
|
|
assert!(released.addr_removed);
|
|
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let connection_data = connections.by_key.get_mut("user1").unwrap();
|
|
let session = connection_data
|
|
.sessions
|
|
.iter_mut()
|
|
.find(|session| session.token == "tok-expire-normal")
|
|
.unwrap();
|
|
session.ts = session.ts.saturating_sub(default_hls_session_ttl_secs() + 1);
|
|
}
|
|
|
|
manager
|
|
.process_due_adaptive_expiry_entries(current_time_secs().saturating_add(default_hls_session_ttl_secs() + 1))
|
|
.await;
|
|
|
|
let connections = manager.connections.read().await;
|
|
let connection_data = connections.by_key.get("user1").unwrap();
|
|
assert_eq!(connection_data.stream_kinds.get(&79), Some(&ConnectionKind::Soft));
|
|
assert!(!connection_data.stream_normal_priorities.contains_key(&78));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_repeated_preserve_for_same_adaptive_session_keeps_single_current_expiry_index() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr_a: SocketAddr = "127.0.0.1:55071".parse().unwrap();
|
|
let addr_b: SocketAddr = "127.0.0.1:55072".parse().unwrap();
|
|
let fp_a = Fingerprint::new("fp-key-a".to_string(), "127.0.0.1".to_string(), addr_a);
|
|
let fp_b = Fingerprint::new("fp-key-b".to_string(), "127.0.0.1".to_string(), addr_b);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr_a).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-reuse",
|
|
virtual_id: 7001,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-a.m3u8",
|
|
addr: &addr_a,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 88,
|
|
meter_uid: 188,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fp_a,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveHls,
|
|
..test_channel(7001)
|
|
},
|
|
user_agent: Cow::Borrowed("ua-a"),
|
|
session_token: Some("tok-reuse"),
|
|
})
|
|
.await;
|
|
let released = manager.release_connection(&addr_a).await;
|
|
assert!(released.addr_removed);
|
|
|
|
manager.add_connection(&addr_b).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 99,
|
|
meter_uid: 199,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fp_b,
|
|
provider: "provider-b".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveDash,
|
|
..test_channel(7002)
|
|
},
|
|
user_agent: Cow::Borrowed("ua-b"),
|
|
session_token: Some("tok-reuse"),
|
|
})
|
|
.await;
|
|
let released = manager.release_connection(&addr_b).await;
|
|
assert!(released.addr_removed);
|
|
|
|
let expiry_index = manager.adaptive_expiry_index.lock().await;
|
|
assert_eq!(expiry_index.len(), 1);
|
|
assert!(expiry_index.contains_key(&AdaptiveExpiryKey {
|
|
username: String::from("user1"),
|
|
session_token: String::from("tok-reuse"),
|
|
uid: 88,
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_release_stream_preserved_path_emits_connection_update_event() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
let mut events = event_manager.get_event_channel();
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55081".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-key-8".to_string(), "127.0.0.1".to_string(), addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-event",
|
|
virtual_id: 8001,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 111,
|
|
meter_uid: 211,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveHls,
|
|
..test_channel(8001)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-event"),
|
|
})
|
|
.await;
|
|
let _ = events.try_recv();
|
|
|
|
let released = manager.release_stream(&addr).await;
|
|
assert!(released.is_none(), "adaptive stream should remain logically preserved");
|
|
|
|
let event = events.try_recv().expect("preserved release should emit an ActiveUser event");
|
|
assert!(matches!(event, EventMessage::ActiveUser(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_release_stream_without_session_removes_adaptive_stream_instead_of_preserving() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55082".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-key-9".to_string(), "127.0.0.1".to_string(), addr);
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 122,
|
|
meter_uid: 222,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveHls,
|
|
..test_channel(8002)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("missing-session"),
|
|
})
|
|
.await;
|
|
|
|
let released = manager.release_stream(&addr).await;
|
|
assert!(released.is_some(), "stream without schedulable expiry must be removed");
|
|
assert!(manager.active_streams().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_due_adaptive_expiry_reschedules_when_session_timestamp_changes() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55083".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-key-10".to_string(), "127.0.0.1".to_string(), addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-reschedule",
|
|
virtual_id: 8003,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 133,
|
|
meter_uid: 233,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveHls,
|
|
..test_channel(8003)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-reschedule"),
|
|
})
|
|
.await;
|
|
let released = manager.release_connection(&addr).await;
|
|
assert!(released.addr_removed);
|
|
|
|
let key = AdaptiveExpiryKey {
|
|
username: String::from("user1"),
|
|
session_token: String::from("tok-reschedule"),
|
|
uid: 133,
|
|
};
|
|
let old_expires_at = {
|
|
let expiry_index = manager.adaptive_expiry_index.lock().await;
|
|
*expiry_index.get(&key).unwrap()
|
|
};
|
|
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let session = connections
|
|
.by_key
|
|
.get_mut("user1")
|
|
.unwrap()
|
|
.sessions
|
|
.iter_mut()
|
|
.find(|session| session.token == "tok-reschedule")
|
|
.unwrap();
|
|
session.ts = session.ts.saturating_add(30);
|
|
}
|
|
|
|
manager.process_due_adaptive_expiry_entries(old_expires_at).await;
|
|
|
|
let new_expires_at = {
|
|
let expiry_index = manager.adaptive_expiry_index.lock().await;
|
|
*expiry_index.get(&key).unwrap()
|
|
};
|
|
assert!(new_expires_at > old_expires_at);
|
|
assert!(manager.active_streams().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_due_adaptive_expiry_removes_stale_index_when_preserved_stream_missing() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55085".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-key-11a".to_string(), "127.0.0.1".to_string(), addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-stale",
|
|
virtual_id: 8004,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/stale.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 134,
|
|
meter_uid: 234,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveHls,
|
|
..test_channel(8004)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-stale"),
|
|
})
|
|
.await;
|
|
let released = manager.release_connection(&addr).await;
|
|
assert!(released.addr_removed);
|
|
|
|
let key = AdaptiveExpiryKey {
|
|
username: String::from("user1"),
|
|
session_token: String::from("tok-stale"),
|
|
uid: 134,
|
|
};
|
|
let old_expires_at = {
|
|
let expiry_index = manager.adaptive_expiry_index.lock().await;
|
|
*expiry_index.get(&key).unwrap()
|
|
};
|
|
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let connection_data = connections.by_key.get_mut("user1").unwrap();
|
|
connection_data.streams.clear();
|
|
}
|
|
|
|
manager.process_due_adaptive_expiry_entries(old_expires_at).await;
|
|
|
|
let expiry_index = manager.adaptive_expiry_index.lock().await;
|
|
assert!(!expiry_index.contains_key(&key));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_due_adaptive_expiry_does_not_block_on_full_cleanup_channel() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55084".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-key-11".to_string(), "127.0.0.1".to_string(), addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-full-channel",
|
|
virtual_id: 8004,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 144,
|
|
meter_uid: 244,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveHls,
|
|
..test_channel(8004)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-full-channel"),
|
|
})
|
|
.await;
|
|
let released = manager.release_connection(&addr).await;
|
|
assert!(released.addr_removed);
|
|
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let session = connections
|
|
.by_key
|
|
.get_mut("user1")
|
|
.unwrap()
|
|
.sessions
|
|
.iter_mut()
|
|
.find(|session| session.token == "tok-full-channel")
|
|
.unwrap();
|
|
session.ts = session.ts.saturating_sub(default_hls_session_ttl_secs() + 1);
|
|
}
|
|
|
|
let (cleanup_tx, mut cleanup_rx) = mpsc::channel(1);
|
|
cleanup_tx
|
|
.send(CleanupEvent::ReleaseConnection { addr })
|
|
.await
|
|
.expect("prefill cleanup channel");
|
|
manager.set_cleanup_sender(cleanup_tx);
|
|
|
|
let process_result = tokio::time::timeout(
|
|
Duration::from_millis(100),
|
|
manager.process_due_adaptive_expiry_entries(current_time_secs().saturating_add(default_hls_session_ttl_secs() + 1)),
|
|
)
|
|
.await;
|
|
|
|
assert!(process_result.is_ok(), "adaptive expiry processing must not await while holding locks");
|
|
|
|
let queued_event = cleanup_rx.try_recv().expect("prefilled cleanup event should remain queued");
|
|
assert!(matches!(queued_event, CleanupEvent::ReleaseConnection { .. }));
|
|
assert!(manager.active_streams().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_preserved_adaptive_stream_reconnect_across_day_sets_previous_session_id() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55085".parse().unwrap();
|
|
let next_addr: SocketAddr = "127.0.0.1:55086".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-rollover-a".to_string(), "127.0.0.1".to_string(), addr);
|
|
let next_fingerprint = Fingerprint::new("fp-rollover-b".to_string(), "127.0.0.1".to_string(), next_addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-rollover",
|
|
virtual_id: 8005,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
let first = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 145,
|
|
meter_uid: 245,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveHls,
|
|
..test_channel(8005)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-rollover"),
|
|
})
|
|
.await
|
|
.expect("initial adaptive session should register");
|
|
|
|
let released = manager.release_connection(&addr).await;
|
|
assert!(released.addr_removed);
|
|
|
|
let forced_old_ts = {
|
|
let mut connections = manager.connections.write().await;
|
|
let stream = connections
|
|
.by_key
|
|
.get_mut("user1")
|
|
.unwrap()
|
|
.streams
|
|
.iter_mut()
|
|
.find(|stream| stream.session_token.as_deref() == Some("tok-rollover"))
|
|
.unwrap();
|
|
stream.ts = stream.ts.saturating_sub(86_400);
|
|
stream.ts
|
|
};
|
|
|
|
manager.add_connection(&next_addr).await;
|
|
let second = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 146,
|
|
meter_uid: 246,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &next_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::LiveDash,
|
|
..test_channel(8005)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-rollover"),
|
|
})
|
|
.await
|
|
.expect("adaptive session should reconnect");
|
|
|
|
assert_eq!(second.previous_session_id, Some((forced_old_ts << 32) | u64::from(first.uid)));
|
|
assert!(second.ts > forced_old_ts);
|
|
assert_eq!(utc_day_from_secs(second.ts), utc_day_from_secs(current_time_secs()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stale_anonymous_socket_registration_is_pruned_by_gc() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let stale_addr: SocketAddr = "127.0.0.1:55011".parse().unwrap();
|
|
let fresh_addr: SocketAddr = "127.0.0.1:55012".parse().unwrap();
|
|
|
|
manager.add_connection(&stale_addr).await;
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let registration = connections.key_by_addr.get_mut(&stale_addr).expect("socket registration should exist");
|
|
registration.ts = registration.ts.saturating_sub(ANON_SOCKET_TTL + 1);
|
|
}
|
|
|
|
if let Some(gc_ts) = &manager.gc_ts {
|
|
gc_ts.store(current_time_secs().saturating_sub(USER_GC_TTL + 1), Ordering::Release);
|
|
}
|
|
|
|
manager.add_connection(&fresh_addr).await;
|
|
|
|
let connections = manager.connections.read().await;
|
|
assert!(!connections.key_by_addr.contains_key(&stale_addr));
|
|
assert!(connections.key_by_addr.contains_key(&fresh_addr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn named_socket_registration_exposes_expiry_deadline() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let stale_addr: SocketAddr = "127.0.0.1:55021".parse().unwrap();
|
|
let fresh_addr: SocketAddr = "127.0.0.1:55022".parse().unwrap();
|
|
let stale_fp = Fingerprint::new("fp-stale".to_string(), "127.0.0.1".to_string(), stale_addr);
|
|
let fresh_fp = Fingerprint::new("fp-fresh".to_string(), "127.0.0.1".to_string(), fresh_addr);
|
|
let mut stale_user = ProxyUserCredentials::default();
|
|
stale_user.username = "user1".to_string();
|
|
stale_user.max_connections = 1;
|
|
let mut fresh_user = ProxyUserCredentials::default();
|
|
fresh_user.username = "user2".to_string();
|
|
fresh_user.max_connections = 1;
|
|
|
|
manager.add_connection(&stale_addr).await;
|
|
manager.add_connection(&fresh_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &stale_user,
|
|
session_token: "tok-stale-deadline",
|
|
virtual_id: 9201,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &stale_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &fresh_user,
|
|
session_token: "tok-fresh-deadline",
|
|
virtual_id: 9202,
|
|
provider: "provider-b",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &fresh_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 201,
|
|
meter_uid: 301,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &stale_fp,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(9201),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-stale-deadline"),
|
|
})
|
|
.await
|
|
.expect("stale stream should register");
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 202,
|
|
meter_uid: 302,
|
|
username: "user2",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fresh_fp,
|
|
provider: "provider-b".intern(),
|
|
stream_channel: &test_adaptive_channel(9202),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-fresh-deadline"),
|
|
})
|
|
.await
|
|
.expect("fresh stream should register");
|
|
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let stale_registration = connections
|
|
.key_by_addr
|
|
.get_mut(&stale_addr)
|
|
.expect("stale registration should exist");
|
|
stale_registration.ts = stale_registration.ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 1);
|
|
}
|
|
|
|
let stale_deadline = manager
|
|
.socket_expiry_deadline(&stale_addr)
|
|
.await
|
|
.expect("stale named socket should have an expiry deadline");
|
|
let fresh_deadline = manager
|
|
.socket_expiry_deadline(&fresh_addr)
|
|
.await
|
|
.expect("fresh named socket should have an expiry deadline");
|
|
assert!(stale_deadline < fresh_deadline);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn touch_http_activity_refreshes_session_and_registration_without_stream() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55024".parse().unwrap();
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-http-touch",
|
|
virtual_id: 9302,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let previous_ts = {
|
|
let mut connections = manager.connections.write().await;
|
|
let previous_ts = {
|
|
let registration = connections.key_by_addr.get_mut(&addr).expect("registration should exist");
|
|
registration.ts = registration.ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 5);
|
|
registration.ts
|
|
};
|
|
let connection_data = connections.by_key.get_mut("user1").expect("user should exist");
|
|
connection_data.sessions[0].ts = connection_data.sessions[0].ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 5);
|
|
previous_ts
|
|
};
|
|
|
|
manager.touch_http_activity("user1", "tok-http-touch", &addr).await;
|
|
|
|
let connections = manager.connections.read().await;
|
|
let registration = connections.key_by_addr.get(&addr).expect("registration should still exist");
|
|
let connection_data = connections.by_key.get("user1").expect("user should still exist");
|
|
assert!(registration.ts > previous_ts);
|
|
assert!(connection_data.sessions[0].ts >= registration.ts);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn touch_http_activity_does_not_reset_stream_started_at_ts() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr1: SocketAddr = "127.0.0.1:55030".parse().unwrap();
|
|
let addr2: SocketAddr = "127.0.0.1:55031".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp".to_string(), "127.0.0.1".to_string(), addr1);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-touch-ts".to_string();
|
|
|
|
manager.add_connection(&addr1).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-hls-ts",
|
|
virtual_id: 7777,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr1,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
// Simulate first HLS segment: creates the stream entry with ts = now
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 601,
|
|
meter_uid: 701,
|
|
username: "user-touch-ts",
|
|
max_connections: 0,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(7777),
|
|
user_agent: Cow::Borrowed("player/1.0"),
|
|
session_token: Some("tok-hls-ts"),
|
|
})
|
|
.await
|
|
.expect("stream should be created");
|
|
|
|
// Record the original stream start timestamp
|
|
let original_ts = {
|
|
let connections = manager.connections.read().await;
|
|
connections
|
|
.by_key
|
|
.get("user-touch-ts")
|
|
.and_then(|data| data.streams.iter().find(|s| s.session_token.as_deref() == Some("tok-hls-ts")))
|
|
.map(|s| s.ts)
|
|
.expect("stream should exist")
|
|
};
|
|
|
|
// Simulate manifest re-fetch (touch_http_activity called with a new addr)
|
|
manager.touch_http_activity("user-touch-ts", "tok-hls-ts", &addr2).await;
|
|
|
|
// stream.ts must NOT have been reset — it represents session start time shown as Duration
|
|
let connections = manager.connections.read().await;
|
|
let stream = connections
|
|
.by_key
|
|
.get("user-touch-ts")
|
|
.and_then(|data| data.streams.iter().find(|s| s.session_token.as_deref() == Some("tok-hls-ts")))
|
|
.expect("stream should still exist");
|
|
assert_eq!(stream.ts, original_ts, "touch_http_activity must not reset the stream start timestamp");
|
|
// Lightweight manifest activity must not move the active stream socket.
|
|
assert_eq!(stream.addr, addr1, "touch_http_activity must not replace the active stream addr");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn touch_http_activity_does_not_migrate_adaptive_stream_to_manifest_addr_on_close() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
let mut events = event_manager.get_event_channel();
|
|
|
|
let segment_addr: SocketAddr = "127.0.0.1:55032".parse().unwrap();
|
|
let manifest_addr: SocketAddr = "127.0.0.1:55033".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-hls-segment".to_string(), "127.0.0.1".to_string(), segment_addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-hls-manifest-touch".to_string();
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&segment_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-hls-manifest-touch",
|
|
virtual_id: 7788,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &segment_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 602,
|
|
meter_uid: 702,
|
|
username: &user.username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(7788),
|
|
user_agent: Cow::Borrowed("player/1.0"),
|
|
session_token: Some("tok-hls-manifest-touch"),
|
|
})
|
|
.await
|
|
.expect("stream should be created");
|
|
|
|
manager
|
|
.touch_http_activity(&user.username, "tok-hls-manifest-touch", &manifest_addr)
|
|
.await;
|
|
|
|
let released = manager.release_connection(&segment_addr).await;
|
|
assert!(released.addr_removed);
|
|
assert!(released.removed_streams.is_empty(), "adaptive close should preserve without history removal");
|
|
assert_eq!(manager.user_connections(&user.username).await, 0);
|
|
assert!(manager.active_streams().await.is_empty(), "preserved adaptive streams are hidden from active snapshots");
|
|
|
|
let connections = manager.connections.read().await;
|
|
let data = connections.by_key.get(&user.username).expect("user should remain for preserved session");
|
|
let stream = data
|
|
.streams
|
|
.iter()
|
|
.find(|stream| stream.session_token.as_deref() == Some("tok-hls-manifest-touch"))
|
|
.expect("preserved stream should remain internally tracked");
|
|
assert!(stream.preserved);
|
|
assert_eq!(stream.addr, segment_addr, "closed segment must not migrate to manifest addr");
|
|
assert!(!data.sessions[0].active_addrs.contains(&manifest_addr));
|
|
drop(connections);
|
|
|
|
let mut saw_preserved_update = false;
|
|
while let Ok(event) = events.try_recv() {
|
|
if matches!(event, EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream)) if stream.addr == segment_addr && stream.preserved) {
|
|
saw_preserved_update = true;
|
|
}
|
|
}
|
|
assert!(saw_preserved_update, "preserving a stream must notify the frontend so adaptive TTL cleanup can hide it");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_unbound_session_addr_prunes_manifest_addr_while_stream_is_active_elsewhere() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let segment_addr: SocketAddr = "127.0.0.1:55034".parse().unwrap();
|
|
let manifest_addr: SocketAddr = "127.0.0.1:55035".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-hls-segment-2".to_string(), "127.0.0.1".to_string(), segment_addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-hls-manifest-clear".to_string();
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&segment_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-hls-manifest-clear",
|
|
virtual_id: 7789,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &segment_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 603,
|
|
meter_uid: 703,
|
|
username: &user.username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(7789),
|
|
user_agent: Cow::Borrowed("player/1.0"),
|
|
session_token: Some("tok-hls-manifest-clear"),
|
|
})
|
|
.await
|
|
.expect("stream should be created");
|
|
|
|
manager.add_connection(&manifest_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-hls-manifest-clear",
|
|
virtual_id: 7789,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &manifest_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
manager
|
|
.clear_unbound_session_addr(&user.username, "tok-hls-manifest-clear", &manifest_addr)
|
|
.await;
|
|
|
|
let connections = manager.connections.read().await;
|
|
assert!(!connections.key_by_addr.contains_key(&manifest_addr));
|
|
let data = connections.by_key.get(&user.username).expect("user should exist");
|
|
assert_eq!(data.streams[0].addr, segment_addr);
|
|
assert!(!data.sessions[0].active_addrs.contains(&manifest_addr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_unbound_session_addr_prunes_touch_only_manifest_addr() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let segment_addr: SocketAddr = "127.0.0.1:55036".parse().unwrap();
|
|
let manifest_addr: SocketAddr = "127.0.0.1:55037".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-hls-segment-3".to_string(), "127.0.0.1".to_string(), segment_addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-hls-manifest-touch-clear".to_string();
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&segment_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-hls-manifest-touch-clear",
|
|
virtual_id: 7790,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &segment_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 604,
|
|
meter_uid: 704,
|
|
username: &user.username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(7790),
|
|
user_agent: Cow::Borrowed("player/1.0"),
|
|
session_token: Some("tok-hls-manifest-touch-clear"),
|
|
})
|
|
.await
|
|
.expect("stream should be created");
|
|
|
|
manager
|
|
.touch_http_activity(&user.username, "tok-hls-manifest-touch-clear", &manifest_addr)
|
|
.await;
|
|
manager
|
|
.clear_unbound_session_addr(&user.username, "tok-hls-manifest-touch-clear", &manifest_addr)
|
|
.await;
|
|
|
|
let connections = manager.connections.read().await;
|
|
assert!(!connections.key_by_addr.contains_key(&manifest_addr));
|
|
let data = connections.by_key.get(&user.username).expect("user should exist");
|
|
assert_eq!(data.streams[0].addr, segment_addr);
|
|
assert_eq!(data.sessions[0].addr, segment_addr);
|
|
assert!(!data.sessions[0].active_addrs.contains(&manifest_addr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn socket_expiry_deadline_does_not_refresh_active_vod_streams_without_activity() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55040".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-vod".to_string(), "127.0.0.1".to_string(), addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-vod-expiry".to_string();
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-vod-expiry",
|
|
virtual_id: 8888,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/movie.mkv",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let mut channel = test_channel(8888);
|
|
channel.item_type = PlaylistItemType::Video;
|
|
channel.cluster = XtreamCluster::Video;
|
|
channel.url = "http://localhost/movie.mkv".intern();
|
|
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 602,
|
|
meter_uid: 702,
|
|
username: "user-vod-expiry",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &channel,
|
|
user_agent: Cow::Borrowed("player/1.0"),
|
|
session_token: Some("tok-vod-expiry"),
|
|
})
|
|
.await
|
|
.expect("vod stream should be created");
|
|
|
|
let previous_registration_ts = {
|
|
let mut connections = manager.connections.write().await;
|
|
let registration = connections.key_by_addr.get_mut(&addr).expect("registration should exist");
|
|
registration.ts = registration.ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 5);
|
|
registration.ts
|
|
};
|
|
|
|
let deadline = manager
|
|
.socket_expiry_deadline(&addr)
|
|
.await
|
|
.expect("VOD streams should stay scheduled for expiry tracking");
|
|
|
|
let unchanged_registration_ts = {
|
|
let connections = manager.connections.read().await;
|
|
connections.key_by_addr.get(&addr).expect("registration should still exist").ts
|
|
};
|
|
|
|
assert_eq!(unchanged_registration_ts, previous_registration_ts);
|
|
assert_eq!(
|
|
deadline,
|
|
previous_registration_ts.saturating_add(manager.active_socket_ttl_secs()),
|
|
"deadline checks must not refresh VOD sockets without real body activity"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn touch_socket_activity_refreshes_registration_without_resetting_stream_start() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55041".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-vod-touch".to_string(), "127.0.0.1".to_string(), addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "user-vod-touch".to_string();
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
let mut channel = test_channel(8889);
|
|
channel.item_type = PlaylistItemType::Video;
|
|
channel.cluster = XtreamCluster::Video;
|
|
channel.url = "http://localhost/movie-2.mkv".intern();
|
|
|
|
let stream = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 603,
|
|
meter_uid: 703,
|
|
username: "user-vod-touch",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &channel,
|
|
user_agent: Cow::Borrowed("player/1.0"),
|
|
session_token: None,
|
|
})
|
|
.await
|
|
.expect("vod stream should be created");
|
|
|
|
let stale_registration_ts = {
|
|
let mut connections = manager.connections.write().await;
|
|
let registration = connections.key_by_addr.get_mut(&addr).expect("registration should exist");
|
|
registration.ts = registration.ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 5);
|
|
registration.ts
|
|
};
|
|
|
|
manager.touch_socket_activity(&addr).await;
|
|
|
|
let (refreshed_registration_ts, stream_started_at) = {
|
|
let connections = manager.connections.read().await;
|
|
let registration_ts = connections.key_by_addr.get(&addr).expect("registration should still exist").ts;
|
|
let stream_started_at = connections
|
|
.by_key
|
|
.get("user-vod-touch")
|
|
.and_then(|data| data.streams.iter().find(|active| active.uid == stream.uid))
|
|
.expect("stream should still exist")
|
|
.ts;
|
|
(registration_ts, stream_started_at)
|
|
};
|
|
|
|
assert!(refreshed_registration_ts > stale_registration_ts);
|
|
assert_eq!(stream_started_at, stream.ts, "body activity must not reset visible stream duration");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_session_addr_prunes_previous_registration_for_socket_bound_session() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let old_addr: SocketAddr = "127.0.0.1:55121".parse().unwrap();
|
|
let new_addr: SocketAddr = "127.0.0.1:55122".parse().unwrap();
|
|
let old_fingerprint = Fingerprint::new("fp-old".to_string(), "127.0.0.1".to_string(), old_addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&old_addr).await;
|
|
manager.add_connection(&new_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-move",
|
|
virtual_id: 9101,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.ts",
|
|
addr: &old_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: true,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 301,
|
|
meter_uid: 401,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &old_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::Live,
|
|
..test_channel(9101)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-move"),
|
|
})
|
|
.await
|
|
.expect("initial live stream should register");
|
|
|
|
manager.update_session_addr("user1", "tok-move", &new_addr).await;
|
|
|
|
let connections = manager.connections.read().await;
|
|
assert!(
|
|
!connections.key_by_addr.contains_key(&old_addr),
|
|
"previous range-request socket registration should be pruned once the session moved"
|
|
);
|
|
assert!(connections.key_by_addr.contains_key(&new_addr));
|
|
|
|
let connection_data = connections.by_key.get("user1").expect("user connection data");
|
|
assert_eq!(connection_data.sessions.len(), 1);
|
|
assert_eq!(connection_data.sessions[0].addr, new_addr);
|
|
assert_eq!(connection_data.streams.len(), 1);
|
|
assert_eq!(connection_data.streams[0].addr, new_addr);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn vod_session_survives_overlapping_and_seek_sockets() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let base_addr: SocketAddr = "127.0.0.1:55131".parse().unwrap();
|
|
let range_addr: SocketAddr = "127.0.0.1:55132".parse().unwrap();
|
|
let seek_addr: SocketAddr = "127.0.0.1:55133".parse().unwrap();
|
|
let base_fingerprint = Fingerprint::new("fp-vod-base".to_string(), "127.0.0.1".to_string(), base_addr);
|
|
let range_fingerprint = Fingerprint::new("fp-vod-range".to_string(), "127.0.0.1".to_string(), range_addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user1");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&base_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-vod",
|
|
virtual_id: 9102,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/movie.mkv",
|
|
addr: &base_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 302,
|
|
meter_uid: 402,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &base_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::Video,
|
|
..test_channel(9102)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-vod"),
|
|
})
|
|
.await
|
|
.expect("initial vod stream should register");
|
|
|
|
manager.add_connection(&range_addr).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 303,
|
|
meter_uid: 403,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &range_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::Video,
|
|
..test_channel(9102)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-vod"),
|
|
})
|
|
.await
|
|
.expect("overlapping range request should reuse the same vod session");
|
|
|
|
assert_eq!(manager.user_connections("user1").await, 1);
|
|
assert!(manager.release_stream(&range_addr).await.is_none());
|
|
let released = manager.release_connection(&range_addr).await;
|
|
assert!(released.addr_removed);
|
|
assert!(released.removed_streams.is_empty());
|
|
|
|
{
|
|
let connections = manager.connections.read().await;
|
|
assert!(connections.key_by_addr.contains_key(&base_addr));
|
|
let connection_data = connections.by_key.get("user1").expect("user connection data");
|
|
assert_eq!(connection_data.sessions[0].addr, base_addr);
|
|
assert_eq!(connection_data.streams[0].addr, base_addr);
|
|
}
|
|
|
|
manager.add_connection(&seek_addr).await;
|
|
manager.update_session_addr("user1", "tok-vod", &seek_addr).await;
|
|
|
|
{
|
|
let connections = manager.connections.read().await;
|
|
assert!(
|
|
connections.key_by_addr.contains_key(&base_addr),
|
|
"existing vod socket must remain registered while the session spans multiple requests"
|
|
);
|
|
assert!(connections.key_by_addr.contains_key(&seek_addr));
|
|
|
|
let connection_data = connections.by_key.get("user1").expect("user connection data");
|
|
assert_eq!(connection_data.sessions[0].addr, seek_addr);
|
|
assert_eq!(connection_data.streams[0].addr, seek_addr);
|
|
}
|
|
|
|
assert!(manager.release_stream(&seek_addr).await.is_none());
|
|
let released = manager.release_connection(&seek_addr).await;
|
|
assert!(released.addr_removed);
|
|
assert!(released.removed_streams.is_empty());
|
|
|
|
let connections = manager.connections.read().await;
|
|
let connection_data = connections.by_key.get("user1").expect("user connection data");
|
|
assert_eq!(connection_data.sessions[0].addr, base_addr);
|
|
assert_eq!(connection_data.streams[0].addr, base_addr);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn catchup_release_connection_preserves_logical_stream_until_session_expires() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55141".parse().unwrap();
|
|
let next_addr: SocketAddr = "127.0.0.1:55142".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-catchup-1".to_string(), "127.0.0.1".to_string(), addr);
|
|
let next_fingerprint = Fingerprint::new("fp-catchup-2".to_string(), "127.0.0.1".to_string(), next_addr);
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user-catchup");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-catchup",
|
|
virtual_id: 9103,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/archive.ts",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
let first = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 304,
|
|
meter_uid: 404,
|
|
username: &user.username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::Catchup,
|
|
..test_channel(9103)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-catchup"),
|
|
})
|
|
.await
|
|
.expect("initial catchup stream should register");
|
|
|
|
let released = manager.release_connection(&addr).await;
|
|
assert!(released.addr_removed);
|
|
assert!(
|
|
released.removed_streams.is_empty(),
|
|
"catchup stream should remain logically active between range requests"
|
|
);
|
|
|
|
assert_eq!(manager.user_connections(&user.username).await, 0);
|
|
assert!(manager.active_streams().await.is_empty());
|
|
|
|
let connections = manager.connections.read().await;
|
|
let preserved_stream = connections
|
|
.by_key
|
|
.get(&user.username)
|
|
.and_then(|data| data.streams.iter().find(|stream| stream.uid == first.uid))
|
|
.expect("preserved catchup stream should stay internally tracked");
|
|
assert!(preserved_stream.preserved);
|
|
drop(connections);
|
|
|
|
manager.add_connection(&next_addr).await;
|
|
let second = manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 305,
|
|
meter_uid: 405,
|
|
username: &user.username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &next_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &StreamChannel {
|
|
item_type: PlaylistItemType::Catchup,
|
|
..test_channel(9103)
|
|
},
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-catchup"),
|
|
})
|
|
.await
|
|
.expect("catchup stream should reconnect");
|
|
|
|
assert_eq!(second.uid, first.uid);
|
|
assert_eq!(second.started_at, first.started_at);
|
|
assert!(!second.preserved);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gc_keeps_active_ts_streams_even_when_user_timestamp_is_stale() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55013".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-key-ts".to_string(), "127.0.0.1".to_string(), addr);
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 144,
|
|
meter_uid: 244,
|
|
username: "user1",
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_channel(9001),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: None,
|
|
})
|
|
.await
|
|
.expect("ts stream should register");
|
|
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let connection_data = connections.by_key.get_mut("user1").expect("user entry should exist");
|
|
connection_data.ts = connection_data.ts.saturating_sub(USER_CON_TTL + 1);
|
|
}
|
|
|
|
if let Some(gc_ts) = &manager.gc_ts {
|
|
gc_ts.store(current_time_secs().saturating_sub(USER_GC_TTL + 1), Ordering::Release);
|
|
}
|
|
|
|
manager.active_streams().await;
|
|
|
|
let connections = manager.connections.read().await;
|
|
let connection_data = connections.by_key.get("user1").expect("active user entry must survive gc");
|
|
assert_eq!(connection_data.connections, 1);
|
|
assert_eq!(connection_data.streams.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_activation_keeps_first_hls_slot_uncommitted_before_stream_registration() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user-hls-reserve");
|
|
user.max_connections = 1;
|
|
|
|
let first_addr: SocketAddr = "127.0.0.1:55180".parse().unwrap();
|
|
let second_addr: SocketAddr = "127.0.0.1:55181".parse().unwrap();
|
|
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-first",
|
|
virtual_id: 9201,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-a.m3u8",
|
|
addr: &first_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-second",
|
|
virtual_id: 9202,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-b.m3u8",
|
|
addr: &second_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let first_admission = manager
|
|
.connection_admission_for_session_activation(&user.username, user.max_connections, 0, "tok-first")
|
|
.await;
|
|
let second_admission = manager
|
|
.connection_admission_for_session_activation(&user.username, user.max_connections, 0, "tok-second")
|
|
.await;
|
|
|
|
assert_eq!(first_admission.permission, UserConnectionPermission::Allowed);
|
|
assert_eq!(first_admission.kind, Some(ConnectionKind::Normal));
|
|
assert_eq!(second_admission.permission, UserConnectionPermission::Allowed);
|
|
|
|
let connections = manager.connections.read().await;
|
|
let connection_data = connections.by_key.get(&user.username).expect("user connection data");
|
|
assert_eq!(connection_data.connections, 0);
|
|
assert_eq!(connection_data.counts.normal, 0);
|
|
assert_eq!(connection_data.streams.len(), 0);
|
|
assert!(
|
|
connection_data
|
|
.sessions
|
|
.iter()
|
|
.find(|session| session.token == "tok-first")
|
|
.is_some_and(|session| !session.lifecycle.is_counted())
|
|
);
|
|
assert!(
|
|
connection_data
|
|
.sessions
|
|
.iter()
|
|
.find(|session| session.token == "tok-second")
|
|
.is_some_and(|session| !session.lifecycle.is_counted())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn binding_reserved_sessions_keeps_hard_and_soft_counts_stable() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user-hls-soft");
|
|
user.max_connections = 1;
|
|
user.soft_connections = 1;
|
|
|
|
let first_addr: SocketAddr = "127.0.0.1:55182".parse().unwrap();
|
|
let second_addr: SocketAddr = "127.0.0.1:55183".parse().unwrap();
|
|
let first_fingerprint = Fingerprint::new("fp-hls-1".to_string(), "127.0.0.1".to_string(), first_addr);
|
|
let second_fingerprint = Fingerprint::new("fp-hls-2".to_string(), "127.0.0.1".to_string(), second_addr);
|
|
|
|
manager.add_connection(&first_addr).await;
|
|
manager.add_connection(&second_addr).await;
|
|
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-normal",
|
|
virtual_id: 9203,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-normal.m3u8",
|
|
addr: &first_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-soft",
|
|
virtual_id: 9204,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-soft.m3u8",
|
|
addr: &second_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let first_admission = manager
|
|
.connection_admission_for_session_activation(&user.username, user.max_connections, user.soft_connections, "tok-normal")
|
|
.await;
|
|
assert_eq!(first_admission.permission, UserConnectionPermission::Allowed);
|
|
assert_eq!(first_admission.kind, Some(ConnectionKind::Normal));
|
|
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 401,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: user.max_connections,
|
|
soft_connections: user.soft_connections,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &first_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(9203),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-normal"),
|
|
})
|
|
.await
|
|
.expect("reserved normal session should bind");
|
|
|
|
let second_admission = manager
|
|
.connection_admission_for_session_activation(&user.username, user.max_connections, user.soft_connections, "tok-soft")
|
|
.await;
|
|
assert_eq!(second_admission.permission, UserConnectionPermission::Allowed);
|
|
assert_eq!(second_admission.kind, Some(ConnectionKind::Soft));
|
|
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 402,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: user.max_connections,
|
|
soft_connections: user.soft_connections,
|
|
connection_kind: ConnectionKind::Soft,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &second_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(9204),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-soft"),
|
|
})
|
|
.await
|
|
.expect("reserved soft session should bind");
|
|
|
|
let connections = manager.connections.read().await;
|
|
let connection_data = connections.by_key.get(&user.username).expect("user connection data");
|
|
assert_eq!(connection_data.connections, 2);
|
|
assert_eq!(connection_data.counts.normal, 1);
|
|
assert_eq!(connection_data.counts.soft, 1);
|
|
assert_eq!(connection_data.streams.len(), 2);
|
|
assert_eq!(
|
|
connection_data.stream_kinds.get(&401),
|
|
Some(&ConnectionKind::Normal),
|
|
"binding a reserved normal session must not increment counts twice"
|
|
);
|
|
assert_eq!(
|
|
connection_data.stream_kinds.get(&402),
|
|
Some(&ConnectionKind::Soft),
|
|
"binding a reserved soft session must keep the soft classification"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn origin_policy_refresh_promotes_counted_soft_session_when_hard_slot_is_available() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user-hls-policy-refresh");
|
|
user.max_connections = 1;
|
|
user.soft_connections = 1;
|
|
|
|
let normal_addr: SocketAddr = "127.0.0.1:55185".parse().unwrap();
|
|
let soft_addr: SocketAddr = "127.0.0.1:55186".parse().unwrap();
|
|
let normal_fingerprint = Fingerprint::new("fp-hls-policy-1".to_string(), "127.0.0.1".to_string(), normal_addr);
|
|
let soft_fingerprint = Fingerprint::new("fp-hls-policy-2".to_string(), "127.0.0.1".to_string(), soft_addr);
|
|
|
|
manager.add_connection(&normal_addr).await;
|
|
manager.add_connection(&soft_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-normal",
|
|
virtual_id: 9210,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-normal.m3u8",
|
|
addr: &normal_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-soft",
|
|
virtual_id: 9211,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-soft.m3u8",
|
|
addr: &soft_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let normal_admission = manager
|
|
.connection_admission_for_session_activation(&user.username, user.max_connections, user.soft_connections, "tok-normal")
|
|
.await;
|
|
assert_eq!(normal_admission.permission, UserConnectionPermission::Allowed);
|
|
assert_eq!(normal_admission.kind, Some(ConnectionKind::Normal));
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 411,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: user.max_connections,
|
|
soft_connections: user.soft_connections,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &normal_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(9210),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-normal"),
|
|
})
|
|
.await
|
|
.expect("normal stream should bind");
|
|
|
|
let soft_admission = manager
|
|
.connection_admission_for_session_activation(&user.username, user.max_connections, user.soft_connections, "tok-soft")
|
|
.await;
|
|
assert_eq!(soft_admission.permission, UserConnectionPermission::Allowed);
|
|
assert_eq!(soft_admission.kind, Some(ConnectionKind::Soft));
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 412,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: user.max_connections,
|
|
soft_connections: user.soft_connections,
|
|
connection_kind: ConnectionKind::Soft,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &soft_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(9211),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-soft"),
|
|
})
|
|
.await
|
|
.expect("soft stream should bind");
|
|
|
|
assert!(manager
|
|
.release_session_streams_and_counted_reservation(&user.username, "tok-normal")
|
|
.await);
|
|
{
|
|
let connections = manager.connections.read().await;
|
|
let connection_data = connections.by_key.get(&user.username).expect("user connection data");
|
|
assert_eq!(connection_data.connections, 1);
|
|
assert_eq!(connection_data.counts.normal, 0);
|
|
assert_eq!(connection_data.counts.soft, 1);
|
|
assert_eq!(
|
|
connection_data
|
|
.sessions
|
|
.iter()
|
|
.find(|session| session.token == "tok-soft")
|
|
.and_then(|session| session.connection_kind),
|
|
Some(ConnectionKind::Soft)
|
|
);
|
|
}
|
|
|
|
let refreshed_kind = manager
|
|
.refresh_session_connection_kind_for_origin_policy(
|
|
&user.username,
|
|
user.max_connections,
|
|
user.soft_connections,
|
|
"tok-soft",
|
|
)
|
|
.await;
|
|
assert_eq!(refreshed_kind, Some(ConnectionKind::Normal));
|
|
|
|
let connections = manager.connections.read().await;
|
|
let connection_data = connections.by_key.get(&user.username).expect("user connection data");
|
|
assert_eq!(connection_data.connections, 1);
|
|
assert_eq!(connection_data.counts.normal, 1);
|
|
assert_eq!(connection_data.counts.soft, 0);
|
|
assert_eq!(
|
|
connection_data
|
|
.sessions
|
|
.iter()
|
|
.find(|session| session.token == "tok-soft")
|
|
.and_then(|session| session.connection_kind),
|
|
Some(ConnectionKind::Normal)
|
|
);
|
|
assert_eq!(connection_data.stream_kinds.get(&412), Some(&ConnectionKind::Normal));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn origin_policy_refresh_returns_none_for_pending_grace_without_available_slot() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user-pending-grace-origin-policy");
|
|
user.max_connections = 1;
|
|
|
|
let active_addr: SocketAddr = "127.0.0.1:55195".parse().unwrap();
|
|
let pending_addr: SocketAddr = "127.0.0.1:55196".parse().unwrap();
|
|
let active_fingerprint = Fingerprint::new("active".to_string(), "127.0.0.1".to_string(), active_addr);
|
|
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-active",
|
|
virtual_id: 9301,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-active.m3u8",
|
|
addr: &active_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 9301,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
max_connections: user.max_connections,
|
|
soft_connections: user.soft_connections,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &active_fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(9301),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-active"),
|
|
})
|
|
.await
|
|
.expect("active stream should bind the only normal slot");
|
|
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-pending",
|
|
virtual_id: 9302,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-pending.m3u8",
|
|
addr: &pending_addr,
|
|
connection_permission: UserConnectionPermission::GracePeriod,
|
|
connection_kind: None,
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.mark_pending_provider(
|
|
&user.username,
|
|
"tok-pending",
|
|
PendingProviderReason::GraceHold,
|
|
current_time_secs() + 30,
|
|
)
|
|
.await
|
|
.expect("pending session should be marked");
|
|
|
|
let refreshed_kind = manager
|
|
.refresh_session_connection_kind_for_origin_policy(
|
|
&user.username,
|
|
user.max_connections,
|
|
user.soft_connections,
|
|
"tok-pending",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(refreshed_kind, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn release_unbound_session_reservation_frees_reserved_slot() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user-release-reservation");
|
|
user.max_connections = 1;
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55184".parse().unwrap();
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-release",
|
|
virtual_id: 9205,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-release.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let admission = manager
|
|
.connection_admission_for_session_activation(&user.username, user.max_connections, 0, "tok-release")
|
|
.await;
|
|
assert_eq!(admission.permission, UserConnectionPermission::Allowed);
|
|
|
|
manager
|
|
.release_unbound_session_reservation(&user.username, "tok-release", None, false)
|
|
.await;
|
|
|
|
let connections = manager.connections.read().await;
|
|
let connection_data = connections.by_key.get(&user.username).expect("user connection data");
|
|
assert_eq!(connection_data.connections, 0);
|
|
assert_eq!(connection_data.counts.normal, 0);
|
|
assert_eq!(connection_data.streams.len(), 0);
|
|
assert!(
|
|
connection_data
|
|
.sessions
|
|
.iter()
|
|
.find(|session| session.token == "tok-release")
|
|
.is_some_and(|session| !session.lifecycle.is_counted())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn release_unbound_session_reservation_ignores_stale_transition_version() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user-stale-release");
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55194".parse().unwrap();
|
|
let stale_version = manager
|
|
.ensure_user_session_placeholder(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-stale-release",
|
|
virtual_id: 9206,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-stale.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
let _ = manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-stale-release",
|
|
virtual_id: 9206,
|
|
provider: "provider-b",
|
|
stream_url: "http://localhost/live-stale-updated.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
manager
|
|
.release_unbound_session_reservation(&user.username, "tok-stale-release", Some(stale_version), true)
|
|
.await;
|
|
|
|
let session = manager
|
|
.get_and_update_user_session(&user.username, "tok-stale-release")
|
|
.await
|
|
.expect("stale rollback must not remove the newer session");
|
|
assert!(session.transition_version > stale_version);
|
|
assert_eq!(session.provider.as_ref(), "provider-b");
|
|
assert_eq!(session.stream_url.as_ref(), "http://localhost/live-stale-updated.m3u8");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_unbound_session_addr_prunes_manifest_addr_without_stream() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let first_addr: SocketAddr = "127.0.0.1:55185".parse().unwrap();
|
|
let second_addr: SocketAddr = "127.0.0.1:55186".parse().unwrap();
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = String::from("user-clear-addr");
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&first_addr).await;
|
|
manager.add_connection(&second_addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-clear-addr",
|
|
virtual_id: 9206,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-clear.m3u8",
|
|
addr: &first_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-clear-addr",
|
|
virtual_id: 9206,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-clear.m3u8",
|
|
addr: &second_addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
manager
|
|
.clear_unbound_session_addr(&user.username, "tok-clear-addr", &second_addr)
|
|
.await;
|
|
|
|
let connections = manager.connections.read().await;
|
|
let session = connections
|
|
.by_key
|
|
.get(&user.username)
|
|
.and_then(|connection_data| connection_data.sessions.iter().find(|session| session.token == "tok-clear-addr"))
|
|
.expect("session should remain");
|
|
assert_eq!(session.addr, first_addr);
|
|
assert_eq!(session.active_addrs, vec![first_addr]);
|
|
}
|
|
|
|
|
|
#[tokio::test]
|
|
async fn get_eviction_candidates_keeps_preserved_streams_evictable() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55300".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-key".to_string(), "192.168.1.100".to_string(), addr);
|
|
let username = "user-eviction-addr";
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = username.to_string();
|
|
user.max_connections = 1;
|
|
user.soft_connections = 0;
|
|
|
|
// Create session first (HLS type = preserved after release)
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-preserved-1",
|
|
virtual_id: 5001,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
|
|
// Create stream + register connection
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 1,
|
|
meter_uid: 0,
|
|
username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(5001),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-preserved-1"),
|
|
})
|
|
.await
|
|
.expect("first stream");
|
|
assert_eq!(manager.user_connections(username).await, 1);
|
|
|
|
// Release -> stream becomes preserved, session becomes uncounted
|
|
manager.release_stream(&addr).await;
|
|
assert_eq!(manager.user_connections(username).await, 0, "preserved stream should not count");
|
|
|
|
let candidates = manager.get_eviction_candidates(username, "192.168.1.100").await;
|
|
assert!(
|
|
candidates.iter().any(|candidate| candidate.addr == addr),
|
|
"preserved stream should remain a direct eviction candidate"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_eviction_candidates_does_not_count_preserved_streams_in_addr_counts() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55801".parse().unwrap();
|
|
let fingerprint =
|
|
Fingerprint::new("fp-preserved-no-count".to_string(), "10.0.0.5".to_string(), addr);
|
|
let username = "user-preserved-addr-count";
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = username.to_string();
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-preserved-addr-count",
|
|
virtual_id: 7000,
|
|
provider: "provider-preserved",
|
|
stream_url: "http://localhost/preserved.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 7000,
|
|
meter_uid: 0,
|
|
username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-preserved".intern(),
|
|
stream_channel: &test_adaptive_channel(7000),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-preserved-addr-count"),
|
|
})
|
|
.await
|
|
.expect("stream should be created");
|
|
|
|
// Release -> stream becomes preserved, session becomes uncounted
|
|
manager.release_stream(&addr).await;
|
|
|
|
// Preserved streams do not consume a counted slot — user_connections should be 0
|
|
assert_eq!(
|
|
manager.user_connections(username).await, 0,
|
|
"preserved stream should not count toward active connections"
|
|
);
|
|
|
|
// But the preserved stream is still a valid eviction candidate (valid victim)
|
|
let candidates = manager.get_eviction_candidates(username, "10.0.0.5").await;
|
|
assert!(
|
|
candidates.iter().any(|c| c.addr == addr),
|
|
"preserved stream should be an eviction candidate"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connection_admission_treats_preserved_stream_as_reserved_capacity() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55305".parse().unwrap();
|
|
let fingerprint = Fingerprint::new("fp-preserved-admission".to_string(), "192.168.1.100".to_string(), addr);
|
|
let username = "user-preserved-admission";
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = username.to_string();
|
|
user.max_connections = 1;
|
|
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.create_user_session(CreateUserSessionParams {
|
|
user: &user,
|
|
session_token: "tok-preserved-admission",
|
|
virtual_id: 6000,
|
|
provider: "provider-a",
|
|
stream_url: "http://localhost/live-preserved.m3u8",
|
|
addr: &addr,
|
|
connection_permission: UserConnectionPermission::Allowed,
|
|
connection_kind: Some(ConnectionKind::Normal),
|
|
socket_bound: false,
|
|
})
|
|
.await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 6000,
|
|
meter_uid: 0,
|
|
username,
|
|
max_connections: user.max_connections,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &fingerprint,
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(6000),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-preserved-admission"),
|
|
})
|
|
.await
|
|
.expect("preserved stream should be created");
|
|
|
|
manager.release_connection(&addr).await;
|
|
assert_eq!(manager.user_connections(username).await, 0, "preserved stream stays uncounted for active snapshots");
|
|
|
|
let admission = manager.connection_admission(username, user.max_connections, 0).await;
|
|
assert_eq!(
|
|
admission.permission,
|
|
UserConnectionPermission::Exhausted,
|
|
"a preserved stream should still reserve capacity against unrelated playback admissions"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connection_admission_for_session_evaluates_admission_for_uncounted_session() {
|
|
// Bug: connection_admission_for_session returns Allowed for any existing session,
|
|
// even if it's uncounted (preserved). This causes strategy evaluation to be skipped.
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55310".parse().unwrap();
|
|
let username = "user-uncounted-admission";
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = username.to_string();
|
|
user.max_connections = 1;
|
|
user.soft_connections = 0;
|
|
|
|
// Create session + counted stream (HLS type = preserved after release)
|
|
manager.add_connection(&addr).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 1,
|
|
meter_uid: 0,
|
|
username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &Fingerprint::new("fp".to_string(), "192.168.1.50".to_string(), addr),
|
|
provider: "provider-a".intern(),
|
|
stream_channel: &test_adaptive_channel(6001),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-uncounted"),
|
|
})
|
|
.await
|
|
.expect("first stream");
|
|
|
|
// Release to preserve (uncounted session, but counts.normal still = 1 from the stream)
|
|
manager.release_stream(&addr).await;
|
|
// After preserve: session is uncounted, stream is preserved, connections=0
|
|
// BUT the stream was removed, so counts.normal is decremented -> counts=0
|
|
assert_eq!(manager.user_connections(username).await, 0);
|
|
|
|
// Add a second stream first - this uses a different session token and consumes the slot
|
|
let second_addr: SocketAddr = "192.168.1.100:55311".parse().unwrap();
|
|
manager.add_connection(&second_addr).await;
|
|
manager
|
|
.update_connection(ActiveUserConnectionParams {
|
|
uid: 2,
|
|
meter_uid: 0,
|
|
username,
|
|
max_connections: 1,
|
|
soft_connections: 0,
|
|
connection_kind: ConnectionKind::Normal,
|
|
priority: 0,
|
|
soft_priority: 0,
|
|
fingerprint: &Fingerprint::new("fp2".to_string(), "192.168.1.100".to_string(), second_addr),
|
|
provider: "provider-b".intern(),
|
|
stream_channel: &test_channel(6002),
|
|
user_agent: Cow::Borrowed("ua"),
|
|
session_token: Some("tok-second"),
|
|
})
|
|
.await
|
|
.expect("second stream");
|
|
// Now user is at limit: connections=1, counts.normal=1, max_connections=1
|
|
assert_eq!(manager.user_connections(username).await, 1);
|
|
|
|
// connection_admission_for_session for the PRESERVED session token should return
|
|
// Exhausted so that eviction strategies can run and evict the preserved stream,
|
|
// freeing a slot for the uncounted session to reactivate
|
|
let admission = manager
|
|
.connection_admission_for_session(username, 1, 0, "tok-uncounted")
|
|
.await;
|
|
assert_eq!(
|
|
admission.permission,
|
|
UserConnectionPermission::Exhausted,
|
|
"uncounted session should not bypass admission when user is at limit; \
|
|
bug: session exists -> Allowed -> strategy evaluation skipped"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn playback_transition_gate_serializes_same_session() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager));
|
|
|
|
let first_guard = manager.acquire_playback_transition("user-gated", "tok-gated").await;
|
|
let second_manager = Arc::clone(&manager);
|
|
let waiting = tokio::spawn(async move {
|
|
let _second_guard = second_manager.acquire_playback_transition("user-gated", "tok-gated").await;
|
|
});
|
|
|
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
|
assert!(
|
|
!waiting.is_finished(),
|
|
"same-session transition gate should block a concurrent transition until the first completes"
|
|
);
|
|
|
|
drop(first_guard);
|
|
tokio::time::timeout(Duration::from_millis(100), waiting)
|
|
.await
|
|
.expect("second transition should proceed once the first guard is released")
|
|
.expect("second transition task should complete");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn playback_transition_gate_cleanup_removes_idle_gates_on_next_acquire() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
|
|
|
let first_guard = manager.acquire_playback_transition("user-gated-cleanup", "tok-first").await;
|
|
assert_eq!(manager.transition_gates.lock().await.len(), 1);
|
|
drop(first_guard);
|
|
|
|
let second_guard = manager.acquire_playback_transition("user-gated-cleanup", "tok-second").await;
|
|
assert_eq!(manager.transition_gates.lock().await.len(), 1);
|
|
drop(second_guard);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn check_divergence_detects_connection_count_mismatch() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager));
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55902".parse().unwrap();
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "div-user-2".to_string();
|
|
|
|
// Create session + increment legacy counter without a counted session
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let data = connections.by_key.entry(user.username.clone())
|
|
.or_insert_with(|| UserConnectionData::new(0, 1, 0));
|
|
// Manually set legacy connections to 1 without any counted session
|
|
data.increment_kind(ConnectionKind::Normal); // connections = 1
|
|
data.add_session(UserSession {
|
|
token: "tok-div-2".to_string(),
|
|
transition_version: 1,
|
|
virtual_id: 9002,
|
|
provider: "provider-a".intern(),
|
|
stream_url: "http://localhost/stream.ts".intern(),
|
|
provider_session_headers: HashMap::new(),
|
|
addr,
|
|
socket_bound: false,
|
|
active_addrs: vec![addr],
|
|
ts: current_time_secs(),
|
|
started_at: current_time_secs(),
|
|
permission: UserConnectionPermission::Allowed,
|
|
connection_kind: None,
|
|
lifecycle: PlaybackLifecycle::Prepared,
|
|
});
|
|
}
|
|
|
|
let connections = manager.connections.read().await;
|
|
let data = connections.by_key.get(&user.username).expect("user connection data");
|
|
let snapshot = ActiveUserManager::collect_divergence_snapshot(data, &user.username);
|
|
drop(connections);
|
|
manager.log_divergence_snapshot(snapshot).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn check_divergence_detects_stream_without_counted_session() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager));
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55903".parse().unwrap();
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "div-user-3".to_string();
|
|
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let data = connections.by_key.entry(user.username.clone())
|
|
.or_insert_with(|| UserConnectionData::new(0, 1, 0));
|
|
|
|
// Add a session with GraceHold pending — exempt from Invariant 1
|
|
data.add_session(UserSession {
|
|
token: "tok-div-3".to_string(),
|
|
transition_version: 1,
|
|
virtual_id: 9003,
|
|
provider: "provider-a".intern(),
|
|
stream_url: "http://localhost/stream.ts".intern(),
|
|
provider_session_headers: HashMap::new(),
|
|
addr,
|
|
socket_bound: false,
|
|
active_addrs: vec![addr],
|
|
ts: current_time_secs(),
|
|
started_at: current_time_secs(),
|
|
permission: UserConnectionPermission::Allowed,
|
|
connection_kind: None,
|
|
lifecycle: PlaybackLifecycle::PendingProvider {
|
|
data: PendingProviderState {
|
|
reason_code: PendingProviderReason::GraceHold,
|
|
created_at: current_time_secs(),
|
|
deadline: current_time_secs() + 30,
|
|
version: 1,
|
|
wake_source: None,
|
|
}
|
|
},
|
|
});
|
|
data.increment_kind(ConnectionKind::Normal);
|
|
|
|
// Add a stream whose session_token doesn't match any counted session
|
|
let orphan_stream = StreamInfo::new(shared::model::StreamInfoParams {
|
|
uid: 903,
|
|
meter_uid: 0,
|
|
username: &user.username,
|
|
addr: &addr,
|
|
client_ip: "127.0.0.1",
|
|
provider: "provider-a".intern(),
|
|
stream_channel: StreamChannel {
|
|
target_id: 1, virtual_id: 9003, provider_id: 1,
|
|
input_name: "provider-a".intern(), item_type: PlaylistItemType::Live,
|
|
cluster: XtreamCluster::Live, group: "g".intern(), title: "t".intern(),
|
|
url: "http://localhost/stream.ts".intern(),
|
|
shared: false, shared_joined_existing: None, shared_stream_id: None, technical: None,
|
|
epg_channel_id: None,
|
|
epg_reference_ts: None,
|
|
},
|
|
user_agent: "ua".to_string(),
|
|
country_code: None,
|
|
session_token: Some("tok-orphan"),
|
|
});
|
|
data.streams.push(orphan_stream);
|
|
data.stream_kinds.insert(903, ConnectionKind::Normal);
|
|
}
|
|
|
|
let connections = manager.connections.read().await;
|
|
let data = connections.by_key.get(&user.username).expect("user connection data");
|
|
let snapshot = ActiveUserManager::collect_divergence_snapshot(data, &user.username);
|
|
drop(connections);
|
|
manager.log_divergence_snapshot(snapshot).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn divergence_log_rate_limited_within_cooldown_window() {
|
|
let config = Config::default();
|
|
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
|
let event_manager = Arc::new(EventManager::new());
|
|
let manager = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager));
|
|
|
|
let addr: SocketAddr = "127.0.0.1:55904".parse().unwrap();
|
|
let mut user = ProxyUserCredentials::default();
|
|
user.username = "div-user-4".to_string();
|
|
|
|
// Create mismatch
|
|
{
|
|
let mut connections = manager.connections.write().await;
|
|
let data = connections.by_key.entry(user.username.clone())
|
|
.or_insert_with(|| UserConnectionData::new(0, 1, 0));
|
|
data.increment_kind(ConnectionKind::Normal);
|
|
data.add_session(UserSession {
|
|
token: "tok-div-4".to_string(),
|
|
transition_version: 1,
|
|
virtual_id: 9004,
|
|
provider: "provider-a".intern(),
|
|
stream_url: "http://localhost/stream.ts".intern(),
|
|
provider_session_headers: HashMap::new(),
|
|
addr,
|
|
socket_bound: false,
|
|
active_addrs: vec![addr],
|
|
ts: current_time_secs(),
|
|
started_at: current_time_secs(),
|
|
permission: UserConnectionPermission::Allowed,
|
|
connection_kind: None,
|
|
lifecycle: PlaybackLifecycle::Prepared,
|
|
});
|
|
}
|
|
|
|
let connections = manager.connections.read().await;
|
|
let data = connections.by_key.get(&user.username).expect("user connection data");
|
|
let snapshot = ActiveUserManager::collect_divergence_snapshot(data, &user.username);
|
|
drop(connections);
|
|
manager.log_divergence_snapshot(snapshot).await;
|
|
let connections = manager.connections.read().await;
|
|
let data = connections.by_key.get(&user.username).expect("user connection data");
|
|
let snapshot = ActiveUserManager::collect_divergence_snapshot(data, &user.username);
|
|
drop(connections);
|
|
manager.log_divergence_snapshot(snapshot).await;
|
|
let connections = manager.connections.read().await;
|
|
let data = connections.by_key.get(&user.username).expect("user connection data");
|
|
let snapshot = ActiveUserManager::collect_divergence_snapshot(data, &user.username);
|
|
drop(connections);
|
|
manager.log_divergence_snapshot(snapshot).await;
|
|
}
|
|
|
|
}
|
|
|
|
//
|
|
// mod tests {
|
|
// use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
|
|
// use std::time::Instant;
|
|
// use std::thread;
|
|
//
|
|
// fn benchmark(ordering: Ordering, iterations: usize) -> u128 {
|
|
// let counter = Arc::new(AtomicUsize::new(0));
|
|
// let start = Instant::now();
|
|
//
|
|
// let handles: Vec<_> = (0..32)
|
|
// .map(|_| {
|
|
// let counter_ref = Arc::clone(&counter);
|
|
// thread::spawn(move || {
|
|
// for _ in 0..iterations {
|
|
// counter_ref.fetch_add(1, ordering);
|
|
// }
|
|
// })
|
|
// })
|
|
// .collect();
|
|
//
|
|
|
|
// for handle in handles {
|
|
// handle.join().unwrap();
|
|
// }
|
|
//
|
|
// let duration = start.elapsed();
|
|
// duration.as_millis()
|
|
// }
|
|
//
|
|
// #[test]
|
|
// fn test_ordering() {
|
|
// let iterations = 1_000_000;
|
|
//
|
|
// let time_acqrel = benchmark(Ordering::SeqCst, iterations);
|
|
// println!("AcqRel: {} ms", time_acqrel);
|
|
//
|
|
// let time_seqcst = benchmark(Ordering::SeqCst, iterations);
|
|
// println!("SeqCst: {} ms", time_seqcst);
|
|
// }
|
|
//
|
|
// }
|