staged m3u to xtream fix (#877)

* staged m3u to xtream fix

* staged m3u to xtream fix

* staged m3u to xtream fix

* fixed race some conditions

* fixed race some conditions

* fixed race some conditions

* fixed race some conditions

* fixed race some conditions

---------

Co-authored-by: euzu <euzu@proton.me>
This commit is contained in:
euzu
2026-09-24 00:51:12 +02:00
committed by GitHub
co-authored by euzu
parent e6ceddec9a
commit fc51a3acd4
12 changed files with 1973 additions and 483 deletions
+154 -1
View File
@@ -38,6 +38,18 @@ type PreemptionCandidate = (PriorityOwner, AllocationId, i8, Instant);
type PriorityKey = (i8, Reverse<Instant>, AllocationId);
const PREEMPTION_COMPLETION_TIMEOUT: Duration = Duration::from_millis(1500);
const EVICTED_PROVIDER_RELEASE_POLL_INTERVAL: Duration = Duration::from_millis(10);
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ProviderReleaseSnapshot {
pub addr: SocketAddr,
single_allocations: Vec<AllocationId>,
shared_subscribers: Vec<SharedSubscriberId>,
}
impl ProviderReleaseSnapshot {
pub fn is_empty(&self) -> bool { self.single_allocations.is_empty() && self.shared_subscribers.is_empty() }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionKind {
@@ -118,6 +130,7 @@ impl AcquireProviderParams<'_> {
#[derive(Debug, Clone)]
struct SharedAllocation {
allocation_id: AllocationId,
origin_subscriber_id: SharedSubscriberId,
allocation: ProviderAllocation,
/// Keyed by unique subscriber id, never by socket: two external clients behind one
/// reverse proxy must not collapse into a single entry.
@@ -444,6 +457,85 @@ impl std::ops::Deref for ActiveProviderManager {
}
impl ActiveProviderManager {
fn has_connections_for_addr(&self, addr: &SocketAddr) -> bool {
let _transition = self.lock_capacity_transition();
let connections = self.read_connections();
connections.single.values().any(|info| info.client_addr == *addr)
|| connections
.shared
.by_key
.values()
.any(|shared| shared.connections.values().any(|subscriber| subscriber.addr == *addr))
}
/// Waits for every allocation currently using an address to leave the registry.
/// Admission handoffs use `wait_for_snapshot_release` to ignore later allocations at that address.
pub async fn wait_for_addr_release(&self, addr: &SocketAddr, timeout: Duration) -> bool {
let deadline = TokioInstant::now() + timeout;
loop {
if !self.has_connections_for_addr(addr) {
return true;
}
let now = TokioInstant::now();
if now >= deadline {
return false;
}
tokio::time::sleep_until((now + EVICTED_PROVIDER_RELEASE_POLL_INTERVAL).min(deadline)).await;
}
}
pub(crate) fn release_snapshot_for_addr(&self, addr: &SocketAddr) -> ProviderReleaseSnapshot {
let _transition = self.lock_capacity_transition();
let connections = self.read_connections();
let single_allocations = connections
.single
.values()
.filter_map(|info| (info.client_addr == *addr).then_some(info.allocation_id))
.collect();
let shared_subscribers = connections
.shared
.by_key
.values()
.flat_map(|shared| {
shared.connections.iter().filter_map(|(id, subscriber)| (subscriber.addr == *addr).then_some(*id))
})
.collect();
ProviderReleaseSnapshot { addr: *addr, single_allocations, shared_subscribers }
}
fn has_connections_from_snapshot(&self, snapshot: &ProviderReleaseSnapshot) -> bool {
let _transition = self.lock_capacity_transition();
let connections = self.read_connections();
snapshot.single_allocations.iter().any(|id| {
connections.single.contains_key(id)
|| connections.shared.shared_by_allocation_id.get(id).is_some_and(|key| {
connections.shared.by_key.get(key).is_some_and(|shared| {
connections.shared.key_by_subscriber.get(&shared.origin_subscriber_id) == Some(key)
})
})
}) || snapshot.shared_subscribers.iter().any(|id| connections.shared.key_by_subscriber.contains_key(id))
}
/// Waits for the kicked transport's original provider allocations to leave the registry.
/// The socket close may be signalled before its response bodies and provider handles are dropped.
pub(crate) async fn wait_for_snapshot_release(
&self,
snapshot: &ProviderReleaseSnapshot,
timeout: Duration,
) -> bool {
let deadline = TokioInstant::now() + timeout;
loop {
if !self.has_connections_from_snapshot(snapshot) {
return true;
}
let now = TokioInstant::now();
if now >= deadline {
return false;
}
tokio::time::sleep_until((now + EVICTED_PROVIDER_RELEASE_POLL_INTERVAL).min(deadline)).await;
}
}
fn upsert_priority_entry(
connections: &mut Connections,
provider_name: &Arc<str>,
@@ -2515,6 +2607,7 @@ impl ActiveProviderManager {
Arc::clone(&shared_key),
SharedAllocation {
allocation_id: handle.allocation_id,
origin_subscriber_id: subscriber_id,
allocation: info.allocation,
connections: HashMap::from([(
subscriber_id,
@@ -2632,7 +2725,7 @@ impl ActiveProviderManager {
#[cfg(test)]
mod tests {
use super::{ActiveProviderManager, ConnectionKind, PlaybackLeaseRef};
use crate::{EventManager, SharedStreamManager};
use crate::{ActiveUserManager, EventManager, SharedStreamManager};
use arc_swap::{ArcSwap, ArcSwapOption};
use shared::{
defaults::{default_probe_user_priority, default_user_priority},
@@ -2873,6 +2966,66 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn release_snapshot_tracks_original_allocation_after_addr_reuse() {
let app_cfg = create_test_app_config_with_pool(2, 3);
let events = Arc::new(EventManager::new());
let provider = ActiveProviderManager::new(&app_cfg, &events);
let addr = SocketAddr::from(([127, 0, 0, 1], 50_022));
let input = "provider_1".intern();
let original = provider
.acquire_connection_with_grace_for_session(&input, &addr, false, 0, ConnectionKind::Normal, Some("old"))
.expect("original allocation");
let original_snapshot = provider.release_snapshot_for_addr(&addr);
assert!(!provider.wait_for_snapshot_release(&original_snapshot, Duration::ZERO).await);
provider.release_handle(&original);
let replacement = provider
.acquire_connection_with_grace_for_session(&input, &addr, false, 0, ConnectionKind::Normal, Some("new"))
.expect("replacement allocation");
let replacement_snapshot = provider.release_snapshot_for_addr(&addr);
assert!(provider.wait_for_snapshot_release(&original_snapshot, Duration::ZERO).await);
assert!(!provider.wait_for_snapshot_release(&replacement_snapshot, Duration::ZERO).await);
let geoip = Arc::new(ArcSwapOption::default());
let users = ActiveUserManager::new(&Config::default(), &geoip, &events);
users.set_pending_provider_release("user", original_snapshot.clone()).await;
users.set_pending_provider_release("user", replacement_snapshot.clone()).await;
assert!(!users.clear_pending_provider_release("user", &original_snapshot).await);
assert_eq!(users.pending_provider_release("user").await, Some(replacement_snapshot.clone()));
assert!(users.clear_pending_provider_release("user", &replacement_snapshot).await);
assert_eq!(users.pending_provider_release("user").await, None);
provider.release_handle(&replacement);
}
#[tokio::test]
async fn release_snapshot_tracks_shared_subscriber_without_waiting_for_other_subscribers() {
let app_cfg = create_test_app_config_single_provider_pool();
let events = Arc::new(EventManager::new());
let provider = ActiveProviderManager::new(&app_cfg, &events);
let input = "provider_1".intern();
let first_addr = SocketAddr::from(([127, 0, 0, 1], 50_023));
let second_addr = SocketAddr::from(([127, 0, 0, 1], 50_024));
let first = SharedSubscriberId::from_stream_uid(50_023);
let second = SharedSubscriberId::from_stream_uid(50_024);
let origin =
provider.acquire_connection(&input, &first_addr, 0, ConnectionKind::Normal).expect("shared origin");
let before_promotion = provider.release_snapshot_for_addr(&first_addr);
assert!(provider.make_shared_connection(&origin, "shared-release", first));
provider
.add_shared_connection(&second_addr, second, "shared-release", 0, ConnectionKind::Normal)
.expect("second subscriber");
let snapshot = provider.release_snapshot_for_addr(&first_addr);
assert!(!provider.wait_for_snapshot_release(&before_promotion, Duration::ZERO).await);
assert!(!provider.wait_for_snapshot_release(&snapshot, Duration::ZERO).await);
provider.release_connection(&first_addr);
assert!(provider.wait_for_snapshot_release(&before_promotion, Duration::ZERO).await);
assert!(provider.wait_for_snapshot_release(&snapshot, Duration::ZERO).await);
assert_eq!(provider.get_provider_connections_count(), 1);
provider.release_connection(&second_addr);
}
#[tokio::test(start_paused = true)]
async fn manifest_retries_without_media_do_not_reserve_capacity() {
let app_cfg = create_test_app_config_with_pool(2, 3);
+26 -1
View File
@@ -1,5 +1,5 @@
use crate::{
active_provider_manager::ConnectionKind,
active_provider_manager::{ConnectionKind, ProviderReleaseSnapshot},
connection_manager::CleanupEvent,
stream::{uses_direct_body_idle_timeout, DIRECT_BODY_IDLE_TIMEOUT_SECS},
ActiveProviderManager, EventManager,
@@ -690,6 +690,9 @@ pub struct ActiveUserManager {
cleanup_tx: tokio::sync::OnceCell<mpsc::Sender<CleanupEvent>>,
provider_manager: tokio::sync::OnceCell<Arc<ActiveProviderManager>>,
transition_gates: Mutex<HashMap<String, Arc<Mutex<()>>>>,
// An evicted stream can keep its provider slot after its user count is released.
// Retain the handoff across bounded admission attempts until the slot is gone.
pending_provider_releases: Mutex<HashMap<String, ProviderReleaseSnapshot>>,
pub dropped_cleanup_events: AtomicU64,
reentry_suppressed_total: AtomicU64,
divergence_cache: Mutex<LruCache<String, DivergenceEntry>>,
@@ -791,6 +794,7 @@ impl ActiveUserManager {
cleanup_tx: tokio::sync::OnceCell::new(),
provider_manager: tokio::sync::OnceCell::new(),
transition_gates: Mutex::new(HashMap::new()),
pending_provider_releases: Mutex::new(HashMap::new()),
dropped_cleanup_events: AtomicU64::new(0),
reentry_suppressed_total: AtomicU64::new(0),
divergence_cache: Mutex::new(LruCache::new(DIVERGENCE_CACHE_CAPACITY)),
@@ -845,6 +849,27 @@ impl ActiveUserManager {
gate.lock_owned().await
}
pub(crate) async fn pending_provider_release(&self, username: &str) -> Option<ProviderReleaseSnapshot> {
self.pending_provider_releases.lock().await.get(username).cloned()
}
pub(crate) async fn set_pending_provider_release(&self, username: &str, snapshot: ProviderReleaseSnapshot) {
self.pending_provider_releases.lock().await.insert(username.to_owned(), snapshot);
}
pub(crate) async fn clear_pending_provider_release(
&self,
username: &str,
snapshot: &ProviderReleaseSnapshot,
) -> bool {
let mut pending = self.pending_provider_releases.lock().await;
if pending.get(username) != Some(snapshot) {
return false;
}
pending.remove(username);
true
}
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()
+85 -15
View File
@@ -18,7 +18,7 @@ use crate::{
};
use log::debug;
use shared::model::{AdmissionStrategy, ConnectionDenied, EventMessage, UserConnectionPermission, VirtualId};
use std::sync::Arc;
use std::{sync::Arc, time::Duration};
use tuliprox_core::model::{AppConfig, Fingerprint, ProxyUserCredentials};
/// The handles admission reads from the running server.
@@ -34,6 +34,7 @@ pub struct AdmissionCtx {
/// Default duration an eviction is remembered if not configured.
pub const DEFAULT_RECENT_EVICTION_REENTRY_TTL_MS: u64 = shared::defaults::DEFAULT_RECENT_EVICTION_REENTRY_TTL_MS;
const EVICTED_PROVIDER_RELEASE_TIMEOUT: Duration = Duration::from_millis(1500);
/// Reentry cooldown from a resolved stream config, falling back to the default when
/// no `reverse_proxy.stream` block is configured.
@@ -287,6 +288,22 @@ enum StrategyLoopResult {
Rejected(crate::AdmissionRejectionReason),
}
async fn pending_provider_release_ready(adm: &AdmissionCtx, username: &str) -> bool {
let Some(snapshot) = adm.active_users.pending_provider_release(username).await else {
return true;
};
if !adm
.connection_manager
.provider_manager
.wait_for_snapshot_release(&snapshot, EVICTED_PROVIDER_RELEASE_TIMEOUT)
.await
{
debug!("Provider allocation for evicted connection {} remains active after close timeout", snapshot.addr);
return false;
}
adm.active_users.clear_pending_provider_release(username, &snapshot).await
}
/// Shared strategy-evaluation loop used by both the initial admission path
/// (`resolve_admission_with_strategies`) and the remaining-strategies path
/// (`evaluate_remaining_strategies_after_grace`).
@@ -361,7 +378,36 @@ where
let connections_before = adm.active_users.user_connections(username).await;
let ttl = get_reentry_ttl(adm);
adm.active_users.mark_recent_eviction_guard_for_addr(&target.addr, *request_addr, ttl).await;
let pending_release = if target.addr == *request_addr {
None
} else {
let snapshot = adm.connection_manager.provider_manager.release_snapshot_for_addr(&target.addr);
if snapshot.is_empty() {
None
} else {
adm.active_users.set_pending_provider_release(username, snapshot.clone()).await;
Some(snapshot)
}
};
adm.connection_manager.release_connection_as_kicked(&target.addr).await;
// This request cannot wait for its own transport to close.
if let Some(snapshot) = pending_release {
let released = adm
.connection_manager
.provider_manager
.wait_for_snapshot_release(&snapshot, EVICTED_PROVIDER_RELEASE_TIMEOUT)
.await;
if !released {
debug!(
"Provider allocation for evicted connection {} remains active after close timeout",
target.addr
);
return StrategyLoopResult::Rejected(crate::AdmissionRejectionReason::UserConnectionsExhausted);
}
if !adm.active_users.clear_pending_provider_release(username, &snapshot).await {
return StrategyLoopResult::Rejected(crate::AdmissionRejectionReason::UserConnectionsExhausted);
}
}
performed_legitimate_eviction = true;
let retry_admission = get_admission_for_request(adm, request).await;
if retry_admission.permission() == UserConnectionPermission::Allowed {
@@ -416,7 +462,34 @@ pub async fn resolve_admission_with_strategies(
let username = request.username;
let admission = get_admission_for_request(adm, &request).await;
if admission.permission() != UserConnectionPermission::Exhausted
&& adm.active_users.pending_provider_release(username).await.is_none()
{
return AdmissionStrategyResolution { admission, grace_mode: None, grace_context: None };
}
let _admission_guard = adm.active_users.acquire_user_admission(username).await;
if !pending_provider_release_ready(adm, username).await {
return AdmissionStrategyResolution {
admission: crate::ConnectionAdmission::exhausted(
crate::AdmissionRejectionReason::UserConnectionsExhausted,
admission.kind(),
),
grace_mode: None,
grace_context: None,
};
}
// Re-read admission now that the gate is held. The first read above happened
// before we queued on the gate, so a request ahead of us may have released
// the very slot we are about to evict somebody for. Walking the strategies on
// the stale snapshot kicks a live connection to free a slot that is already
// free.
let admission = get_admission_for_request(adm, &request).await;
if admission.permission() != UserConnectionPermission::Exhausted {
debug!("Admission became available while waiting on the admission gate for user {username}");
return AdmissionStrategyResolution { admission, grace_mode: None, grace_context: None };
}
@@ -433,20 +506,6 @@ pub async fn resolve_admission_with_strategies(
};
}
let _admission_guard = adm.active_users.acquire_user_admission(username).await;
// Re-read admission now that the gate is held. The first read above happened
// before we queued on the gate, so a request ahead of us may have released
// the very slot we are about to evict somebody for. Walking the strategies on
// the stale snapshot kicks a live connection to free a slot that is already
// free.
let admission = get_admission_for_request(adm, &request).await;
if admission.permission() != UserConnectionPermission::Exhausted {
debug!("Admission became available while waiting on the admission gate for user {username}");
return AdmissionStrategyResolution { admission, grace_mode: None, grace_context: None };
}
let build_grace_ctx = |global_idx: usize| GraceResolutionContext {
strategy_index: global_idx,
strategies: strategies.clone(),
@@ -513,6 +572,17 @@ pub async fn evaluate_remaining_strategies_after_grace(
let _admission_guard = adm.active_users.acquire_user_admission(username).await;
if !pending_provider_release_ready(adm, username).await {
return AdmissionStrategyResolution {
admission: crate::ConnectionAdmission::exhausted(
crate::AdmissionRejectionReason::UserConnectionsExhausted,
original_kind,
),
grace_mode: None,
grace_context: None,
};
}
match evaluate_admission_strategy_loop(
adm,
&request,
+37 -94
View File
@@ -278,32 +278,7 @@ impl MultiProviderLineup {
names
}
/// Attempts to acquire the next available provider from a specific priority group.
///
/// # Parameters
/// - `priority_group`: The provider group to search within.
///
/// # Returns
/// - `ProviderAllocation`: A reference to the next available provider in the specified group.
///
/// # Behavior
/// - Iterates through the providers in the given group in a round-robin manner.
/// - Checks if a provider has available capacity before selecting it.
/// - Uses atomic operations to maintain fair provider selection.
///
/// # Thread Safety
/// - Uses `RwLock` for safe concurrent access.
/// - Ensures fair provider allocation across multiple threads.
///
/// # Example Usage
/// ```text
/// let lineup = MultiProviderLineup::new(&config);
/// match lineup.acquire_next_provider_from_group(priority_group).await {
/// ProviderAllocation::Exhausted => println!("All providers exhausted"),
/// ProviderAllocation::Available(provider) => println!("Provider available {}", provider.name),
/// ProviderAllocation::GracePeriod(provider) => println!("Provider with grace period {}", provider.name),
/// }
/// ```
/// Attempts to acquire the next available provider from a priority group in round-robin order.
fn acquire_next_provider_from_group(
priority_group: &ProviderPriorityGroup,
grace: bool,
@@ -400,93 +375,61 @@ impl MultiProviderLineup {
}
/// Attempts to acquire a provider from the lineup based on priority and availability.
///
/// # Returns
/// - `ProviderAllocation`: A reference to the acquired provider if allocation was successful.
///
/// # Behavior
/// - The method iterates through provider priority groups in a round-robin fashion.
/// - It attempts to allocate a provider from the highest priority group first.
/// - If a provider has available capacity, it is returned.
/// - If all providers in a group are exhausted, it moves to the next group.
/// - Updates the internal index to ensure fair distribution of requests.
///
/// # Thread Safety
/// - Uses atomic operations (`AtomicUsize`) for thread-safe indexing.
/// - Uses `RwLock` for thread-safe provider allocation.
///
/// # Example Usage
/// ```text
/// let lineup = MultiProviderLineup::new(&config);
/// match lineup.acquire().await {
/// ProviderAllocation::Exhausted => println!("All providers exhausted"),
/// ProviderAllocation::Available(provider) => println!("Provider available {}", provider.name),
/// ProviderAllocation::GracePeriod(provider) => println!("Provider with grace period {}", provider.name),
/// }
/// ```
#[cfg(test)]
fn acquire(&self, with_grace: bool, grace_period_timeout_secs: u64) -> ProviderAllocation {
self.acquire_excluding(with_grace, grace_period_timeout_secs, &std::collections::HashSet::new())
}
fn scan_priority_groups<T>(
&self,
with_grace: bool,
mut select: impl FnMut(&ProviderPriorityGroup, bool) -> Option<T>,
) -> Option<T> {
// Prefer providers with available capacity (no grace allocations),
// scanning priority groups from highest -> lowest.
for priority_group in &self.providers {
if let Some(item) = select(priority_group, false) {
return Some(item);
}
}
if !with_grace {
return None;
}
// If every provider is at capacity, allow grace allocations while respecting priority order.
for priority_group in &self.providers {
if let Some(item) = select(priority_group, true) {
return Some(item);
}
}
None
}
fn acquire_excluding(
&self,
with_grace: bool,
grace_period_timeout_secs: u64,
excluded_providers: &std::collections::HashSet<Arc<str>>,
) -> ProviderAllocation {
// Prefer providers with available capacity (no grace allocations),
// scanning priority groups from highest -> lowest.
for priority_group in &self.providers {
self.scan_priority_groups(with_grace, |priority_group, grace| {
let allocation = Self::acquire_next_provider_from_group(
priority_group,
false,
grace,
grace_period_timeout_secs,
excluded_providers,
);
if !matches!(allocation, ProviderAllocation::Exhausted) {
return allocation;
}
}
if !with_grace {
return ProviderAllocation::Exhausted;
}
// If every provider is at capacity, allow grace allocations while respecting priority order.
for priority_group in &self.providers {
let allocation = Self::acquire_next_provider_from_group(
priority_group,
true,
grace_period_timeout_secs,
excluded_providers,
);
if !matches!(allocation, ProviderAllocation::Exhausted) {
return allocation;
}
}
ProviderAllocation::Exhausted
(!matches!(allocation, ProviderAllocation::Exhausted)).then_some(allocation)
})
.unwrap_or(ProviderAllocation::Exhausted)
}
// it intended to use with redirects to cycle through provider
// It is intended to use with redirects to cycle through providers.
fn get_next(&self, grace_period_timeout_secs: u64) -> Option<Arc<ProviderConfig>> {
// Prefer providers with available capacity (no grace allocations),
// scanning priority groups from highest -> lowest.
for priority_group in &self.providers {
if let Some(config) = Self::get_next_provider_from_group(priority_group, false, grace_period_timeout_secs) {
return Some(config);
}
}
// If no provider is available, allow grace.
for priority_group in &self.providers {
if let Some(config) = Self::get_next_provider_from_group(priority_group, true, grace_period_timeout_secs) {
return Some(config);
}
}
None
self.scan_priority_groups(true, |priority_group, grace| {
Self::get_next_provider_from_group(priority_group, grace, grace_period_timeout_secs)
})
}
#[cfg(test)]