feat: hls content encoding support and shared session between different targets (#789)

* feat: key shared hls sessions by origin id
* feat: support hls content encoding
* fix: coderabbitai findings
* Fix: Custom headers are lost on provider://
In the manual redirect path, the abstract provider:// URL is compared against the resolved HTTP URL.
This causes the request to be marked as "scrubbed" before the first real redirect even occurs, stripping out headers like X-API-Key.
This commit is contained in:
knylbyte
2026-07-15 10:40:30 +02:00
committed by GitHub
parent 8f65e98241
commit c70fbc671e
64 changed files with 10183 additions and 2729 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ dashmap.workspace = true
hyper = "1.10.1"
hyper-util = "0.1.20"
socket2 = { version = "0.6.4", features = ["all"] }
async-compression = { version = "0.4.42", features = ["tokio", "gzip", "zlib"] }
async-compression = { version = "0.4.42", features = ["tokio", "brotli", "deflate", "gzip", "zlib", "zstd"] }
chrono-tz = "0.10.4"
zeroize.workspace = true
uuid = { version = "1.23.3", features = ["v4"] }
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@ use crate::{
api_utils::{create_api_proxy_user, mark_response_as_uncompressed},
endpoints::hls_api::{
build_virtual_hls_entry_path, hls_panel_provisioning_poll_manifest_response,
resolve_hls_virtual_input_for_target,
resolve_hls_virtual_source_for_target,
},
model::{
create_custom_video_stream_response, hls_custom_video_manifest_response_with_virtual_id,
@@ -410,19 +410,20 @@ async fn cvs_provisioning_manifest_api(
Err(response) => return *response,
};
let Some(input) = resolve_hls_virtual_input_for_target(&app_state, &target, query.id).await else {
return StatusCode::NOT_FOUND.into_response();
let source = match resolve_hls_virtual_source_for_target(&app_state, &target, query.id).await {
Ok(source) => source,
Err(status) => return status.into_response(),
};
let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, query.id);
let original_hls_entry_path = build_virtual_hls_entry_path(&target, &source.input, &user, query.id);
let server_path = app_state.app_config.get_user_server_info(&user).and_then(|server| server.path);
hls_panel_provisioning_poll_manifest_response(
&app_state,
&fingerprint,
&user,
&target,
&input,
query.id,
&source.input,
&source.stream_identity,
&original_hls_entry_path,
server_path.as_deref(),
)
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -11,7 +11,7 @@ use crate::{
endpoints::{
hls_api::{
build_virtual_hls_entry_path, handle_hls_stream_request, hls_admission_failure_manifest_response,
hls_custom_video_manifest_response, m3u_archive_epg_reference_ts,
hls_custom_video_manifest_response, m3u_archive_epg_reference_ts, HlsEntryStreamIdentity,
},
xtream_api::{ApiStreamContext, ApiStreamRequest},
},
@@ -305,6 +305,8 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
input: &input,
user: &user,
session_reservation_ttl_secs: get_session_reservation_ttl_secs(app_state, pli.item_type),
content_representation:
crate::api::model::ProviderContentRepresentationMode::for_playback_extension(extension),
},
None,
)
@@ -396,6 +398,13 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
let archive_reference = m3u_archive_epg_reference_ts(&pli.url);
// Reverse proxy mode — only route genuine HLS into the HLS handler, not DASH
if is_session_request && extension == shared::defaults::HLS_EXT {
let Some(stream_identity) = HlsEntryStreamIdentity::from_playlist_item(&pli) else {
error!(
"HLS input stream identity missing for virtual_id={}; refresh target playlist",
pli.virtual_id
);
return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response();
};
let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, pli.virtual_id);
return handle_hls_stream_request(
fingerprint,
@@ -405,7 +414,7 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
user_session.as_ref(),
&pli.url,
archive_reference,
pli.virtual_id,
stream_identity,
&input,
req_headers,
connection_permission,
+18 -3
View File
@@ -16,7 +16,7 @@ use crate::{
endpoints::{
hls_api::{
build_virtual_hls_entry_path, handle_hls_stream_request, hls_admission_failure_manifest_response,
hls_custom_video_manifest_response,
hls_custom_video_manifest_response, HlsEntryStreamIdentity,
},
xmltv_api::{get_empty_epg_response, get_epg_path_for_target_by_type, serve_short_epg},
},
@@ -483,6 +483,8 @@ async fn xtream_player_api_stream(
input: &input,
user: &user,
session_reservation_ttl_secs: get_session_reservation_ttl_secs(app_state, item_type),
content_representation:
crate::api::model::ProviderContentRepresentationMode::for_playback_extension(playback_ext),
},
None,
)
@@ -576,6 +578,13 @@ async fn xtream_player_api_stream(
let is_session_request = is_session_based_playback(item_type, Some(playback_ext));
// Reverse proxy mode — only route genuine HLS into the HLS handler, not DASH
if is_session_request && playback_ext == shared::defaults::HLS_EXT {
let Some(stream_identity) = HlsEntryStreamIdentity::from_playlist_item(&pli) else {
error!(
"HLS input stream identity missing for virtual_id={}; refresh target playlist",
pli.virtual_id
);
return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response();
};
let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, pli.virtual_id);
return handle_hls_stream_request(
fingerprint,
@@ -585,7 +594,7 @@ async fn xtream_player_api_stream(
user_session.as_ref(),
&stream_url,
None,
pli.virtual_id,
stream_identity,
&input,
req_headers,
connection_permission,
@@ -747,6 +756,10 @@ pub(in crate::api) async fn xtream_player_api_stream_with_token(
// Reverse proxy mode — only route genuine HLS into the HLS handler, not DASH
if is_session_request && playback_ext == Some(shared::defaults::HLS_EXT) {
let Some(stream_identity) = HlsEntryStreamIdentity::from_playlist_item(&pli) else {
error!("HLS input stream identity missing for virtual_id={virtual_id}; refresh target playlist");
return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response();
};
let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, virtual_id);
return handle_hls_stream_request(
fingerprint,
@@ -756,7 +769,7 @@ pub(in crate::api) async fn xtream_player_api_stream_with_token(
None,
&pli.url,
None,
virtual_id,
stream_identity,
&input,
req_headers,
UserConnectionPermission::Allowed,
@@ -1780,6 +1793,7 @@ mod tests {
input_name: "local".intern(),
channel_no: 1,
source_ordinal: 0,
input_stream_id: "".intern(),
}
}
@@ -1930,6 +1944,7 @@ mod tests {
input_name: "strong".intern(),
channel_no: 0,
source_ordinal: 0,
input_stream_id: "813563".intern(),
}
}
+90 -44
View File
@@ -7,13 +7,13 @@ use std::{
atomic::{AtomicU64, Ordering},
Arc, RwLock as StdRwLock,
},
time::{Duration, SystemTime},
time::SystemTime,
};
use tokio::{
fs::{self, File, OpenOptions},
io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, SeekFrom},
sync::{Mutex, RwLock},
time::timeout,
time::{timeout_at, Instant},
};
pub const DEFAULT_HLS_CACHE_PATH: &str = "/tmp/tuliprox/cache/hls";
@@ -173,6 +173,31 @@ pub struct StagedCacheObject {
pub size: u64,
}
/// Typed source used to distinguish a decoded-object size violation from a filesystem failure.
#[derive(Debug, thiserror::Error)]
#[error("hls cache object exceeds configured size limit {limit}")]
pub(crate) struct HlsCacheObjectLimitError {
limit: u64,
}
impl HlsCacheObjectLimitError {
pub(crate) fn limit(&self) -> u64 { self.limit }
}
pub(crate) fn hls_cache_object_limit_from_io(error: &io::Error) -> Option<&HlsCacheObjectLimitError> {
let mut source: &(dyn std::error::Error + 'static) = error.get_ref()?;
loop {
if let Some(limit_error) = source.downcast_ref::<HlsCacheObjectLimitError>() {
return Some(limit_error);
}
source = source.source()?;
}
}
fn cache_object_limit_error(limit: u64) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, HlsCacheObjectLimitError { limit })
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum CacheInvalidationOutcome {
Invalidated,
@@ -258,11 +283,11 @@ impl HlsSegmentCache {
self.write_temp_and_commit_inner(key, &mut reader, None).await
}
pub async fn write_temp_and_commit_with_timeout<K, R>(
pub async fn write_temp_and_commit_with_deadline<K, R>(
&self,
key: &K,
mut reader: R,
deadline: Duration,
deadline: Instant,
) -> io::Result<CachedSegmentMetadata>
where
K: HlsCacheObjectKey,
@@ -271,11 +296,11 @@ impl HlsSegmentCache {
self.write_temp_and_commit_inner(key, &mut reader, Some(deadline)).await
}
pub async fn stage_temp_with_timeout<K, R>(
pub async fn stage_temp_with_deadline<K, R>(
&self,
key: &K,
mut reader: R,
deadline: Duration,
deadline: Instant,
) -> io::Result<StagedCacheObject>
where
K: HlsCacheObjectKey,
@@ -301,8 +326,9 @@ impl HlsSegmentCache {
K: HlsCacheObjectKey,
{
self.ensure_cache_root_marker().await?;
if staged.size > self.max_object_bytes.load(Ordering::Acquire) {
return Err(io::Error::new(io::ErrorKind::InvalidData, "hls cache object exceeds configured size limit"));
let max_object_bytes = self.max_object_bytes.load(Ordering::Acquire);
if staged.size > max_object_bytes {
return Err(cache_object_limit_error(max_object_bytes));
}
let final_path = self.path_for_key(key);
let Some(parent) = final_path.parent() else {
@@ -314,12 +340,8 @@ impl HlsSegmentCache {
let mut capacity = self.capacity.lock().await;
if !capacity.initialized || capacity.cache_path != cache_path {
let (total_bytes, session_bytes) = scan_committed_cache_usage(&cache_path).await?;
*capacity = CacheCapacityState {
cache_path: cache_path.clone(),
initialized: true,
total_bytes,
session_bytes,
};
*capacity =
CacheCapacityState { cache_path: cache_path.clone(), initialized: true, total_bytes, session_bytes };
}
let old_size = match fs::metadata(&final_path).await {
Ok(metadata) => metadata.len(),
@@ -360,7 +382,7 @@ impl HlsSegmentCache {
&self,
key: &K,
reader: &mut R,
deadline: Option<Duration>,
deadline: Option<Instant>,
) -> io::Result<CachedSegmentMetadata>
where
K: HlsCacheObjectKey,
@@ -374,12 +396,15 @@ impl HlsSegmentCache {
&self,
key: &K,
reader: &mut R,
deadline: Option<Duration>,
deadline: Option<Instant>,
) -> io::Result<StagedCacheObject>
where
K: HlsCacheObjectKey,
R: AsyncRead + Unpin,
{
if deadline.is_some_and(|deadline| deadline <= Instant::now()) {
return Err(io::Error::new(io::ErrorKind::TimedOut, "hls cache object write timed out"));
}
self.ensure_cache_root_marker().await?;
let final_path = self.path_for_key(key);
let Some(parent) = final_path.parent() else {
@@ -388,19 +413,25 @@ impl HlsSegmentCache {
fs::create_dir_all(parent).await?;
let (temp_path, mut temp_file) = self.create_temp_file(key).await?;
if deadline.is_some_and(|deadline| deadline <= Instant::now()) {
drop(temp_file);
let _ = fs::remove_file(&temp_path).await;
self.unregister_temp_file(&temp_path).await;
return Err(io::Error::new(io::ErrorKind::TimedOut, "hls cache object write timed out"));
}
let copy = async {
let max_object_bytes = self.max_object_bytes.load(Ordering::Acquire);
let mut limited = reader.take(max_object_bytes.saturating_add(1));
let size = tokio::io::copy(&mut limited, &mut temp_file).await?;
if size > max_object_bytes {
return Err(io::Error::new(io::ErrorKind::InvalidData, "hls cache object exceeds configured size limit"));
return Err(cache_object_limit_error(max_object_bytes));
}
temp_file.flush().await?;
drop(temp_file);
Ok::<u64, io::Error>(size)
};
let copy_result = if let Some(deadline) = deadline {
if let Ok(result) = timeout(deadline, copy).await {
if let Ok(result) = timeout_at(deadline, copy).await {
result
} else {
let _ = fs::remove_file(&temp_path).await;
@@ -436,11 +467,7 @@ impl HlsSegmentCache {
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
};
if result.is_ok()
&& size > 0
&& capacity.initialized
&& capacity.cache_path == self.cache_path_snapshot()
{
if result.is_ok() && size > 0 && capacity.initialized && capacity.cache_path == self.cache_path_snapshot() {
capacity.total_bytes = capacity.total_bytes.saturating_sub(size);
let session_component = key.session_path_component();
if let Some(session_bytes) = capacity.session_bytes.get_mut(&session_component) {
@@ -610,13 +637,7 @@ impl HlsSegmentCache {
async fn ensure_cache_root_marker(&self) -> io::Result<()> {
let cache_path = self.cache_path_snapshot();
if self
.marker_path
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
== Some(&cache_path)
{
if self.marker_path.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() == Some(&cache_path) {
return Ok(());
}
ensure_not_root_like_cache_path(&cache_path)?;
@@ -744,9 +765,17 @@ impl Default for HlsSegmentCache {
#[cfg(test)]
mod tests {
use super::{CacheInvalidationOutcome, HlsSegmentCache, MapCacheKey, SegmentCacheKey, TransientObjectCacheKey};
use crate::api::model::{build_transient_resource_id, ProxySessionId};
use std::{collections::HashSet, io, sync::Arc, time::{Duration, SystemTime}};
use super::{
hls_cache_object_limit_from_io, CacheInvalidationOutcome, HlsSegmentCache, MapCacheKey, SegmentCacheKey,
TransientObjectCacheKey,
};
use crate::api::model::{build_transient_resource_id, HlsOriginResourceFetchError, ProxySessionId};
use std::{
collections::HashSet,
io,
sync::Arc,
time::{Duration, SystemTime},
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn cache_key() -> SegmentCacheKey { SegmentCacheKey::new(ProxySessionId("proxy_session".to_string()), 123, "ts") }
@@ -817,19 +846,35 @@ mod tests {
}
#[tokio::test]
async fn write_temp_and_commit_with_timeout_cleans_active_temp_file() {
async fn write_temp_and_commit_with_deadline_cleans_active_temp_file() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let cache = HlsSegmentCache::with_cache_path(temp_dir.path());
let key = cache_key();
let (_writer, reader) = tokio::io::duplex(64);
let result = cache.write_temp_and_commit_with_timeout(&key, reader, Duration::from_millis(1)).await;
let result = cache
.write_temp_and_commit_with_deadline(&key, reader, tokio::time::Instant::now() + Duration::from_millis(1))
.await;
assert_eq!(result.expect_err("commit should time out").kind(), io::ErrorKind::TimedOut);
assert!(!cache.has_active_temp_files().await);
assert_eq!(cache.metadata(&key).await.expect("metadata should read"), None);
}
#[tokio::test]
async fn exhausted_write_deadline_rejects_even_an_immediately_available_body() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let cache = HlsSegmentCache::with_cache_path(temp_dir.path());
let key = cache_key();
let result = cache.write_temp_and_commit_with_deadline(&key, &b"ready"[..], tokio::time::Instant::now()).await;
assert_eq!(result.expect_err("expired deadline must time out").kind(), io::ErrorKind::TimedOut);
assert!(!cache.has_active_temp_files().await);
assert_eq!(cache.metadata(&key).await.expect("metadata should read"), None);
assert_eq!(std::fs::read_dir(temp_dir.path()).expect("cache root reads").count(), 0);
}
#[tokio::test]
async fn cache_object_write_rejects_bytes_above_the_configured_limit() {
let temp_dir = tempfile::tempdir().expect("tempdir");
@@ -839,8 +884,15 @@ mod tests {
let result = cache.write_bytes_and_commit(&key, b"four").await;
assert!(result.is_err());
let error = result.expect_err("decoded object above the configured limit must fail");
let limit_error = hls_cache_object_limit_from_io(&error).expect("typed object-limit source");
assert_eq!(limit_error.limit(), 3);
assert!(matches!(
HlsOriginResourceFetchError::cache_body(&error),
HlsOriginResourceFetchError::CacheObjectLimit { limit: 3 }
));
assert!(cache.metadata(&key).await.expect("metadata").is_none());
assert!(!cache.has_active_temp_files().await);
}
#[tokio::test]
@@ -854,10 +906,7 @@ mod tests {
tokio::fs::create_dir_all(temp_dir.path().join("orphan")).await.expect("orphan dir");
let cutoff = SystemTime::now();
let removed = cache
.delete_orphan_session_dirs(&HashSet::from([active]), cutoff)
.await
.expect("cleanup");
let removed = cache.delete_orphan_session_dirs(&HashSet::from([active]), cutoff).await.expect("cleanup");
assert_eq!(removed, 1);
assert!(temp_dir.path().join("active").exists());
@@ -880,10 +929,7 @@ mod tests {
filetime::set_file_mtime(temp_dir.path().join(fresh.0.clone()), filetime::FileTime::from_system_time(future))
.expect("set fresh mtime");
let removed = cache
.delete_orphan_session_dirs(&HashSet::new(), cutoff)
.await
.expect("cleanup");
let removed = cache.delete_orphan_session_dirs(&HashSet::new(), cutoff).await.expect("cleanup");
assert_eq!(removed, 1, "stale orphan dir should be removed");
assert!(!temp_dir.path().join(stale.0).exists());
+4 -7
View File
@@ -1,8 +1,7 @@
use super::{
renderer_candidate_window_proxy_seqs, safe_proxy_session_id, CacheInvalidationOutcome, HlsCacheMetrics,
HlsSegmentCache, HlsSession, HlsExpiredSessionReason, HlsSessionHandle, HlsSessionStore, MapCacheKey,
MapCacheStatus, ProxyMapId,
ProxySessionId, SegmentCacheKey, SegmentCacheStatus, TransientObjectCacheKey,
HlsExpiredSessionReason, HlsSegmentCache, HlsSession, HlsSessionHandle, HlsSessionStore, MapCacheKey,
MapCacheStatus, ProxyMapId, ProxySessionId, SegmentCacheKey, SegmentCacheStatus, TransientObjectCacheKey,
};
use crate::{api::model::AppState, model::HlsCacheConfig};
use arc_swap::ArcSwap;
@@ -203,10 +202,8 @@ impl HlsGarbageCollector {
for session in &sessions {
active_session_ids.insert(session.read().await.proxy_session_id.clone());
}
report.orphan_session_dirs_deleted = self
.cache
.delete_orphan_session_dirs(&active_session_ids, gc_start)
.await?;
report.orphan_session_dirs_deleted =
self.cache.delete_orphan_session_dirs(&active_session_ids, gc_start).await?;
let mut pending_deletions = Vec::new();
for session in &sessions {
let mut session = session.write().await;
+10 -10
View File
@@ -1,5 +1,8 @@
use crate::api::model::proxy::header_policy::{HeaderProtocol, HopByHopHeader};
use crate::model::ReverseProxyDisabledHeaderConfig;
use crate::{
api::model::proxy::header_policy::{HeaderProtocol, HopByHopHeader},
model::ReverseProxyDisabledHeaderConfig,
utils::content_coding::force_accept_encoding_identity,
};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue};
use std::collections::HashMap;
@@ -25,10 +28,8 @@ pub fn scrub_hls_origin_headers(headers: &mut HeaderMap, disabled_headers: Optio
.flat_map(|value| value.split(','))
.filter_map(|name| HeaderName::from_bytes(name.trim().as_bytes()).ok())
.collect::<Vec<_>>();
names.extend(headers
.keys()
.filter(|name| should_remove_hls_origin_header(name.as_str(), disabled_headers))
.cloned()
names.extend(
headers.keys().filter(|name| should_remove_hls_origin_header(name.as_str(), disabled_headers)).cloned(),
);
for name in names {
headers.remove(name);
@@ -55,9 +56,8 @@ pub fn extract_hls_provider_session_header_map(headers: &HeaderMap) -> HeaderMap
.collect::<Vec<_>>();
let mut session_headers = HeaderMap::new();
if let Some(cookie_header) = (!cookies.is_empty())
.then(|| cookies.join("; "))
.and_then(|value| HeaderValue::from_str(&value).ok())
if let Some(cookie_header) =
(!cookies.is_empty()).then(|| cookies.join("; ")).and_then(|value| HeaderValue::from_str(&value).ok())
{
session_headers.insert(header::COOKIE, cookie_header);
}
@@ -91,7 +91,7 @@ pub fn hls_origin_headers_with_provider_session(
pub fn force_identity_without_range(headers: &mut HeaderMap) {
headers.remove(header::RANGE);
headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("identity"));
force_accept_encoding_identity(headers);
}
#[cfg(test)]
+18
View File
@@ -5,13 +5,23 @@ const PROXY_SESSION_ID_LEN: usize = 22;
const PROXY_SESSION_ID_KEY_CONTEXT: &str = "tuliprox:hls-cache:proxy-session-id-key:v1";
/// Stable Tuliprox content identity for a live HLS source.
///
/// `stream_ref` is the immutable input-stream ID captured before target mapping.
/// Together with `input_id`, it identifies the origin content across targets;
/// target IDs and virtual IDs must never be used as `stream_ref`.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct HlsSessionKey {
/// Internal ID of the configured Tuliprox input.
pub input_id: u16,
/// Exact, non-empty origin/provider stream ID represented as a string.
pub stream_ref: String,
}
impl HlsSessionKey {
/// Creates a key from the configured input ID and its immutable input-stream ID.
///
/// Callers must pass the origin/provider ID captured before target mapping, not
/// a target-specific or virtual ID.
pub fn new(input_id: u16, stream_ref: impl Into<String>) -> Self {
Self { input_id, stream_ref: stream_ref.into() }
}
@@ -56,6 +66,14 @@ mod tests {
assert_ne!(first, different_stream);
}
#[test]
fn live_hls_session_key_preserves_alphanumeric_input_stream_id() {
let key = HlsSessionKey::new(7, "m3u-channel_A42");
assert_eq!(key.stream_ref, "m3u-channel_A42");
assert_eq!(key.stable_value(), "input:7|hls|m3u-channel_A42");
}
#[test]
fn live_hls_session_key_does_not_contain_origin_or_provider_url_parts() {
let key = HlsSessionKey::new(7, "80510");
+14 -25
View File
@@ -98,11 +98,8 @@ impl HlsAccessLeasePendingDeadline {
}
const fn tightened_with(self, candidate: Self) -> Self {
let deadline_ms = if self.deadline_ms() <= candidate.deadline_ms() {
self.deadline_ms()
} else {
candidate.deadline_ms()
};
let deadline_ms =
if self.deadline_ms() <= candidate.deadline_ms() { self.deadline_ms() } else { candidate.deadline_ms() };
match (self, candidate) {
(Self::FollowUp { .. }, _) | (_, Self::FollowUp { .. }) => Self::FollowUp { deadline_ms },
(Self::Bootstrap { .. }, Self::Bootstrap { .. }) => Self::Bootstrap { deadline_ms },
@@ -302,10 +299,7 @@ impl HlsAccessLeaseStore {
.filter(|lease| lease.proxy_session_id == *proxy_session_id)
.map(|lease| lease.lease_id.clone())
.collect::<Vec<_>>();
lease_ids
.into_iter()
.filter_map(|lease_id| self.by_lease_id.remove(&lease_id))
.collect()
lease_ids.into_iter().filter_map(|lease_id| self.by_lease_id.remove(&lease_id)).collect()
}
pub fn clear(&mut self) -> usize {
@@ -325,7 +319,12 @@ impl HlsAccessLeaseStore {
.map(|lease| lease.username.clone())
}
pub fn response_snapshot(&mut self, lease_id: &HlsAccessLeaseId, path_proxy_session_id: &ProxySessionId, now_ms: u64) -> Option<HlsAccessLease> {
pub fn response_snapshot(
&mut self,
lease_id: &HlsAccessLeaseId,
path_proxy_session_id: &ProxySessionId,
now_ms: u64,
) -> Option<HlsAccessLease> {
let lease = self.by_lease_id.get_mut(lease_id)?;
if &lease.proxy_session_id != path_proxy_session_id {
return None;
@@ -347,10 +346,8 @@ impl HlsAccessLeaseStore {
}
lease.refresh_validity(now_ms);
if lease_state_allows_use(lease.state) {
lease.response_flag = Some(HlsAccessLeaseResponseFlag::ChannelUnavailable {
reason,
set_at_ms: now_ms,
});
lease.response_flag =
Some(HlsAccessLeaseResponseFlag::ChannelUnavailable { reason, set_at_ms: now_ms });
marked += 1;
}
}
@@ -819,9 +816,7 @@ mod tests {
let marked = store.mark_channel_unavailable_for_session(
&proxy_session_id,
17_000,
HlsAccessLeaseChannelUnavailableReason::SegmentPermanentFailure {
status: Some(StatusCode::NOT_FOUND),
},
HlsAccessLeaseChannelUnavailableReason::SegmentPermanentFailure { status: Some(StatusCode::NOT_FOUND) },
);
assert_eq!(marked, 1);
@@ -865,11 +860,7 @@ mod tests {
set_at_ms: 2_000
})
));
assert!(store
.response_snapshot(&other_lease_id, &proxy_session_id, 2_000)
.unwrap()
.response_flag
.is_none());
assert!(store.response_snapshot(&other_lease_id, &proxy_session_id, 2_000).unwrap().response_flag.is_none());
}
#[test]
@@ -1147,9 +1138,7 @@ mod tests {
store.prepare_access_lease(lease(activated_id.clone(), &proxy_session_id.0, 1_000));
store.prepare_access_lease(lease(denied_id.clone(), &proxy_session_id.0, 1_000));
store.prepare_access_lease(lease(expired_id.clone(), &proxy_session_id.0, 1_000));
assert!(store
.activate_access_lease(&idle_id, &proxy_session_id, 2_000, timing(1_000, 15_000))
.is_activated());
assert!(store.activate_access_lease(&idle_id, &proxy_session_id, 2_000, timing(1_000, 15_000)).is_activated());
assert!(store
.activate_access_lease(&activated_id, &proxy_session_id, 2_000, timing(5_000, 15_000))
.is_activated());
+35 -63
View File
@@ -1,19 +1,17 @@
use super::{
build_rewrite_secret_fingerprint, safe_proxy_session_id, safe_session_key, GarbageCollectionPolicy,
HlsAccessLease, HlsAccessLeaseActivation, HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseId,
HlsAccessLeaseLifecycleSnapshot, HlsAccessLeasePendingDeadline, HlsAccessLeaseSessionSnapshot,
HlsAccessLeaseState, HlsAccessLeaseStore, HlsAccessLeaseTiming, HlsAccessLeaseTouch, HlsCacheMetrics,
HlsExpiredSessionMarker, HlsExpiredSessionReason, HlsGarbageCollector, HlsLifecycleEvent, HlsLifecycleEventKey,
HlsLifecycleManager, HlsMapWorkerPool,
HlsOriginSource, HlsQosRegistry, HlsSegmentCache, HlsSegmentRepairManager, HlsSegmentWorkerPool, HlsSessionHandle,
HlsSessionKey, HlsSessionStore, HlsSessionStoreOutcome, ProxySessionId, SegmentFetchPolicy,
build_rewrite_secret_fingerprint, safe_proxy_session_id, safe_session_key, GarbageCollectionPolicy, HlsAccessLease,
HlsAccessLeaseActivation, HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseId,
HlsAccessLeaseLifecycleSnapshot, HlsAccessLeasePendingDeadline, HlsAccessLeaseSessionSnapshot, HlsAccessLeaseState,
HlsAccessLeaseStore, HlsAccessLeaseTiming, HlsAccessLeaseTouch, HlsCacheMetrics, HlsExpiredSessionMarker,
HlsExpiredSessionReason, HlsGarbageCollector, HlsLifecycleEvent, HlsLifecycleEventKey, HlsLifecycleManager,
HlsMapWorkerPool, HlsOriginSource, HlsQosRegistry, HlsSegmentCache, HlsSegmentRepairManager, HlsSegmentWorkerPool,
HlsSessionHandle, HlsSessionKey, HlsSessionStore, HlsSessionStoreOutcome, ProxySessionId, SegmentFetchPolicy,
TransientResourceStore,
};
use crate::{
api::model::{ActiveProviderManager, ActiveUserManager, AppState},
model::{AppConfig, HlsCacheConfig, StripConfig},
model::{AppConfig, HlsCacheConfig, HlsManifestRecoveryBurstConfig, StripConfig},
};
use crate::model::HlsManifestRecoveryBurstConfig;
use arc_swap::ArcSwap;
use log::{debug, error, info};
use shared::utils::sanitize_sensitive_info;
@@ -82,10 +80,7 @@ fn hls_pending_manifest_follow_up_window_ms(target_duration: Option<u32>) -> u64
target_duration_secs.saturating_mul(2_000).max(10_000)
}
fn hls_pending_manifest_follow_up_deadline(
now_ms: u64,
target_duration: Option<u32>,
) -> HlsAccessLeasePendingDeadline {
fn hls_pending_manifest_follow_up_deadline(now_ms: u64, target_duration: Option<u32>) -> HlsAccessLeasePendingDeadline {
HlsAccessLeasePendingDeadline::FollowUp {
deadline_ms: now_ms.saturating_add(hls_pending_manifest_follow_up_window_ms(target_duration)),
}
@@ -219,11 +214,7 @@ impl HlsProxyManager {
Self::with_hls_cache_config_and_secret_enabled(config, rewrite_secret, true)
}
fn with_hls_cache_config_and_secret_enabled(
config: &HlsCacheConfig,
rewrite_secret: &[u8],
enabled: bool,
) -> Self {
fn with_hls_cache_config_and_secret_enabled(config: &HlsCacheConfig, rewrite_secret: &[u8], enabled: bool) -> Self {
let segment_fetch_policy = SegmentFetchPolicy::from_config(config);
let global_fetch_semaphore = Arc::new(Semaphore::new(segment_fetch_policy.max_global_segment_fetches));
let sessions = Arc::new(HlsSessionStore::new());
@@ -320,10 +311,8 @@ impl HlsProxyManager {
account_name: &Arc<str>,
now_ms: u64,
) -> bool {
let key = HlsAccountOverlapCooldownKey {
input_name: Arc::clone(input_name),
account_name: Arc::clone(account_name),
};
let key =
HlsAccountOverlapCooldownKey { input_name: Arc::clone(input_name), account_name: Arc::clone(account_name) };
let mut cooldowns = self.account_overlap_cooldowns.write().await;
let Some(cooldown) = cooldowns.get(&key).copied() else {
return false;
@@ -382,10 +371,7 @@ impl HlsProxyManager {
return;
}
let key = HlsAccountOverlapCooldownKey { input_name, account_name };
self.account_overlap_cooldowns
.write()
.await
.insert(key.clone(), HlsAccountOverlapCooldown { until_ms });
self.account_overlap_cooldowns.write().await.insert(key.clone(), HlsAccountOverlapCooldown { until_ms });
debug!(
"HLS account overlap cooldown set for input {} account {} until {} ms after {}",
sanitize_sensitive_info(key.input_name.as_ref()),
@@ -402,11 +388,8 @@ impl HlsProxyManager {
.reverse_proxy
.as_ref()
.map_or(app_config.encrypt_secret, |reverse_proxy| reverse_proxy.rewrite_secret);
let hls_config = config
.reverse_proxy
.as_ref()
.and_then(|reverse_proxy| reverse_proxy.hls_cache.as_ref())
.cloned();
let hls_config =
config.reverse_proxy.as_ref().and_then(|reverse_proxy| reverse_proxy.hls_cache.as_ref()).cloned();
let enabled = hls_config.is_some();
let hls_config =
hls_config.unwrap_or_else(|| HlsCacheConfig::from(&shared::model::HlsCacheConfigDto::default()));
@@ -483,10 +466,7 @@ impl HlsProxyManager {
now_ms: u64,
reason: HlsAccessLeaseChannelUnavailableReason,
) -> usize {
self.access_leases
.write()
.await
.mark_channel_unavailable_for_session(proxy_session_id, now_ms, reason)
self.access_leases.write().await.mark_channel_unavailable_for_session(proxy_session_id, now_ms, reason)
}
pub async fn mark_access_lease_channel_unavailable(
@@ -560,11 +540,12 @@ impl HlsProxyManager {
target_duration: Option<u32>,
) -> bool {
let deadline = hls_pending_manifest_follow_up_deadline(now_ms, target_duration);
let lease = self
.access_leases
.write()
.await
.mark_pending_manifest_follow_up_for_lease(lease_id, proxy_session_id, now_ms, deadline);
let lease = self.access_leases.write().await.mark_pending_manifest_follow_up_for_lease(
lease_id,
proxy_session_id,
now_ms,
deadline,
);
if let Some(lease) = lease {
self.schedule_access_lease_validity(&lease).await;
debug!(
@@ -585,11 +566,11 @@ impl HlsProxyManager {
target_duration: Option<u32>,
) -> usize {
let deadline = hls_pending_manifest_follow_up_deadline(now_ms, target_duration);
let leases = self
.access_leases
.write()
.await
.mark_pending_manifest_follow_up_for_session(proxy_session_id, now_ms, deadline);
let leases = self.access_leases.write().await.mark_pending_manifest_follow_up_for_session(
proxy_session_id,
now_ms,
deadline,
);
for lease in &leases {
self.schedule_access_lease_validity(lease).await;
}
@@ -690,11 +671,7 @@ impl HlsProxyManager {
now_ms: u64,
) -> Option<HlsExpiredSessionMarker> {
self.sessions
.expired_session_marker(
proxy_session_id,
now_ms,
self.session_idle_timeout_ms().saturating_mul(2).max(1),
)
.expired_session_marker(proxy_session_id, now_ms, self.session_idle_timeout_ms().saturating_mul(2).max(1))
.await
}
@@ -789,9 +766,7 @@ impl HlsProxyManager {
}
if snapshot.state != HlsAccessLeaseState::Expired && snapshot.state != HlsAccessLeaseState::Denied {
let due_at_ms = if snapshot.state == HlsAccessLeaseState::Pending {
snapshot
.pending_deadline
.map_or(snapshot.valid_until_ms, HlsAccessLeasePendingDeadline::deadline_ms)
snapshot.pending_deadline.map_or(snapshot.valid_until_ms, HlsAccessLeasePendingDeadline::deadline_ms)
} else {
snapshot.valid_until_ms
};
@@ -1129,7 +1104,10 @@ mod tests {
utils::FileLockManager,
};
use arc_swap::{ArcSwap, ArcSwapOption};
use shared::model::{ConfigPaths, HlsCacheConfigDto, HlsManifestRecoveryBurstLevel, HlsStripConfigDto, HlsStripMode, ReverseProxyConfigDto};
use shared::model::{
ConfigPaths, HlsCacheConfigDto, HlsManifestRecoveryBurstLevel, HlsStripConfigDto, HlsStripMode,
ReverseProxyConfigDto,
};
use std::sync::Arc;
fn empty_paths() -> ConfigPaths {
@@ -1207,10 +1185,7 @@ mod tests {
assert_eq!(manager.cache_duration_seconds(), 99);
assert_eq!(manager.transient_resource_ttl_ms(), 99_000);
assert_eq!(manager.session_idle_timeout_ms(), 55_000);
assert_eq!(
manager.manifest_recovery_burst().level,
HlsManifestRecoveryBurstLevel::Balanced
);
assert_eq!(manager.manifest_recovery_burst().level, HlsManifestRecoveryBurstLevel::Balanced);
assert_eq!(manager.strip().mode, HlsStripMode::Seconds);
assert_eq!(manager.strip().value, 7);
assert_eq!(session.read().await.segment_prefetch_queue.max_prefetch_depth(), 4);
@@ -1222,10 +1197,7 @@ mod tests {
assert!(!manager.is_enabled());
assert_eq!(
manager
.run_garbage_collection_once(1_000)
.await
.expect("disabled gc should no-op"),
manager.run_garbage_collection_once(1_000).await.expect("disabled gc should no-op"),
super::super::GarbageCollectionReport::default()
);
@@ -1,6 +1,6 @@
use super::{
HlsAccountBindingProtection, HlsFreshManifestRequiredReason, HlsManifestCommitRequirement,
HlsOriginAccountBinding, HlsSession, HlsSessionHandle, HlsSessionMode, HlsSessionStoreOutcome,
HlsAccountBindingProtection, HlsFreshManifestRequiredReason, HlsManifestCommitRequirement, HlsOriginAccountBinding,
HlsSession, HlsSessionHandle, HlsSessionMode, HlsSessionStoreOutcome,
};
use std::time::Duration;
@@ -78,7 +78,7 @@ pub fn hls_committed_manifest_body_for_request(
candidate.rendered_at_ms >= started_at_ms,
now_ms,
) && manifest_rendered_after_required_boundary(Some(candidate.rendered_at_ms), options))
.then_some(candidate.body)
.then_some(candidate.body)
})
}
@@ -109,9 +109,7 @@ pub async fn hls_manifest_commit_requirement(
};
}
if matches!(session_outcome, HlsSessionStoreOutcome::Created) {
return HlsManifestCommitRequirement::FreshCommitRequired {
reason: HlsFreshManifestRequiredReason::ColdStart,
};
return HlsManifestCommitRequirement::FreshCommitRequired { reason: HlsFreshManifestRequiredReason::ColdStart };
}
let session = session.read().await;
@@ -181,10 +179,12 @@ fn can_serve_committed_manifest(
matches!(policy, HlsCachedManifestPolicy::AllowInitialNoMediaYet)
&& committed_manifest_valid_at(candidate, now_ms)
}
HlsAccountBindingProtection::Expired => matches!(policy, HlsCachedManifestPolicy::AllowInitialNoMediaYet)
&& (refreshed_after_wait_started
|| (session.origin_account_binding.as_ref().is_some_and(HlsOriginAccountBinding::is_active)
&& committed_manifest_valid_at(candidate, now_ms))),
HlsAccountBindingProtection::Expired => {
matches!(policy, HlsCachedManifestPolicy::AllowInitialNoMediaYet)
&& (refreshed_after_wait_started
|| (session.origin_account_binding.as_ref().is_some_and(HlsOriginAccountBinding::is_active)
&& committed_manifest_valid_at(candidate, now_ms)))
}
}
}
+655 -105
View File
@@ -1,6 +1,7 @@
use super::{
extract_hls_provider_session_header_map, safe_origin_log_value, safe_session_key, HlsAccountBindingProtection,
HlsBoundAccountAcquireErrorKind, HlsSessionHandle, HlsSessionMode, TimelineMapError,
extract_hls_provider_session_header_map, log_hls_origin_content_coding, safe_origin_log_value, safe_session_key,
HlsAccountBindingProtection, HlsBoundAccountAcquireErrorKind, HlsOriginContentCodingObjectKind,
HlsOriginContentCodingSource, HlsSessionHandle, HlsSessionMode, TimelineMapError,
};
use crate::{
model::{
@@ -11,20 +12,26 @@ use crate::{
parse_manifest_timing, parse_origin_manifest_timeline, parse_origin_media_manifest, OriginManifestParseOutcome,
ParsedOriginManifestTimeline,
},
utils::request::{
send_input_with_retry_and_provider_policy_with_manual_redirects_and_options_result,
send_input_with_retry_and_provider_policy_with_options_result, RequestFetchOptions,
utils::{
content_coding::{
apply_outbound_content_coding_policy, content_decoding_error_from_io, decode_response_to_identity,
is_http_body_transport_error, read_utf8_limited, ContentBodyReadError, ContentCoding,
ContentCodingDetection, ContentCodingError, OutboundContentCodingPolicy,
},
request::{
send_input_with_retry_and_provider_policy_with_manual_redirects_and_options_result,
send_input_with_retry_and_provider_policy_with_options_result, RequestFetchOptions,
},
},
};
use axum::http::{header, HeaderMap, StatusCode};
use futures::StreamExt;
use log::{debug, warn};
use reqwest::Client;
use shared::{
model::{HlsManifestRecoveryBurstLevel, HlsManifestRecoveryBurstPlan, InputFetchMethod, HlsStripMode},
model::{HlsManifestRecoveryBurstLevel, HlsManifestRecoveryBurstPlan, HlsStripMode, InputFetchMethod},
utils::sanitize_sensitive_info,
};
use std::{collections::HashMap, fmt, future::Future, sync::Arc, time::Duration};
use std::{collections::HashMap, fmt, future::Future, io, sync::Arc, time::Duration};
use tokio::{task::JoinSet, time::timeout};
use url::Url;
@@ -33,7 +40,7 @@ const DEFAULT_HLS_TARGET_DURATION_SECS: u32 = 15;
const HLS_MANIFEST_HOST_SWITCH_BASE_WINDOW_SEGMENTS: u32 = 3;
const HLS_MANIFEST_HOST_SWITCH_MAX_FAILURE_THRESHOLD: u32 = 5;
const DEFAULT_HLS_SESSION_IDLE_TIMEOUT_SECS: u64 = 300;
const MAX_HLS_MANIFEST_BYTES: usize = 2 * 1024 * 1024;
pub(crate) const MAX_HLS_MANIFEST_BYTES: usize = 2 * 1024 * 1024;
/// Origin manifest entrypoint snapshot for live HLS refreshes.
#[derive(Clone)]
@@ -137,16 +144,64 @@ pub fn classify_origin_manifest_status(status: StatusCode) -> OriginManifestStat
OriginManifestStatusClass::NonRetryableFailure
}
#[derive(Debug)]
pub enum OriginManifestFetchError {
#[derive(Debug, thiserror::Error)]
pub(crate) enum OriginManifestFetchError {
#[error("origin manifest returned permanent status {0}")]
PermanentStatus(StatusCode),
#[error("origin manifest returned retryable status {0}, retry_after_ms={1:?}")]
RetryableStatus(StatusCode, Option<u64>),
#[error("origin manifest retry attempts exhausted")]
RetryExhausted,
#[error("origin manifest returned non-retryable status {0}")]
NonRetryableStatus(StatusCode),
#[error("origin manifest request failed: {0}")]
Request(String),
#[error("origin manifest redirect failed: {0}")]
Redirect(String),
#[error("origin manifest request timed out")]
Timeout,
#[error("origin provider unavailable: {0:?}")]
ProviderUnavailable(HlsBoundAccountAcquireErrorKind),
#[error("origin manifest content coding failed: {0}")]
ContentCoding(ContentCodingError),
#[error("origin manifest decoding failed: coding={coding:?}")]
ContentDecoding { coding: ContentCoding },
#[error("decoded origin manifest exceeds configured limit {limit}")]
DecodedBodyLimitExceeded { limit: usize },
#[error("decoded origin manifest is not valid UTF-8: valid_up_to={valid_up_to} error_len={error_len:?}")]
InvalidUtf8 { valid_up_to: usize, error_len: Option<usize> },
}
impl OriginManifestFetchError {
/// Returns a fixed/numeric diagnostic label without origin-controlled strings.
pub(crate) fn log_label(&self) -> String {
match self {
Self::PermanentStatus(status) => format!("permanent_status status={}", status.as_u16()),
Self::RetryableStatus(status, _) => format!("retryable_status status={}", status.as_u16()),
Self::RetryExhausted => "retry_exhausted".to_string(),
Self::NonRetryableStatus(status) => format!("non_retryable_status status={}", status.as_u16()),
Self::Request(_) => "request".to_string(),
Self::Redirect(_) => "redirect".to_string(),
Self::Timeout => "timeout".to_string(),
Self::ProviderUnavailable(kind) => format!("provider_unavailable kind={}", kind.as_log_label()),
Self::ContentCoding(error) => match error {
ContentCodingError::InvalidHeader => "content_coding class=invalid_header".to_string(),
ContentCodingError::Unsupported(_) => "content_coding class=unsupported".to_string(),
ContentCodingError::EncodedPartialContent => "content_coding class=encoded_partial_content".to_string(),
ContentCodingError::PrefixRead(error) => content_decoding_error_from_io(error).map_or_else(
|| "content_coding class=prefix_read".to_string(),
|error| format!("content_decoding coding={}", error.coding.as_http_token()),
),
},
Self::ContentDecoding { coding, .. } => {
format!("content_decoding coding={}", coding.as_http_token())
}
Self::DecodedBodyLimitExceeded { limit } => format!("decoded_body_limit limit={limit}"),
Self::InvalidUtf8 { valid_up_to, error_len } => {
format!("invalid_utf8 valid_up_to={valid_up_to} error_len={error_len:?}")
}
}
}
}
#[derive(Debug)]
@@ -482,40 +537,85 @@ async fn fetch_hls_origin_manifest_initial_global_policy(
);
let fetch_options = RequestFetchOptions::with_attempt_idle_timeout(Duration::from_millis(
context.origin_manifest_timeout_ms.max(1),
));
let response_result = if context.use_manual_redirects {
send_input_with_retry_and_provider_policy_with_manual_redirects_and_options_result(
&context.app_config,
&context.no_redirect_client,
&input_source,
Some(&context.headers),
context.origin_entry.url(),
MAX_MANUAL_REDIRECTS,
fetch_options,
)
.await
} else {
send_input_with_retry_and_provider_policy_with_options_result(
&context.app_config,
&context.client,
&input_source,
Some(&context.headers),
context.origin_entry.url(),
fetch_options,
)
.await
};
let response_result = response_result.map_err(|err| origin_manifest_fetch_error_from_request_error(&err))?;
let provider_url_index = response_result.provider_url_index;
let resolved_request_url =
resolved_hls_manifest_request_url_from_input(&input_source, provider_url_index, context.origin_entry.url());
response_to_fetched_manifest(
response_result.response,
provider_url_index,
resolved_request_url,
context.origin_manifest_timeout_ms,
)
.await
))
.with_content_coding(OutboundContentCodingPolicy::Identity)
.without_resource_retries();
let attempts = context.retry_policy.attempt_count();
// This HLS loop owns the logical manifest-attempt budget. Each iteration may traverse one bounded provider URL
// failover cycle and redirect chain, but the generic resource-retry counter is not nested beneath it.
for attempt_index in 0..attempts {
let response_result = if context.use_manual_redirects {
send_input_with_retry_and_provider_policy_with_manual_redirects_and_options_result(
&context.app_config,
&context.no_redirect_client,
&input_source,
Some(&context.headers),
context.origin_entry.url(),
MAX_MANUAL_REDIRECTS,
fetch_options,
)
.await
} else {
send_input_with_retry_and_provider_policy_with_options_result(
&context.app_config,
&context.client,
&input_source,
Some(&context.headers),
context.origin_entry.url(),
fetch_options,
)
.await
};
let fetch_result = match response_result {
Ok(response_result) => {
let provider_url_index = response_result.provider_url_index;
let resolved_request_url = resolved_hls_manifest_request_url_from_input(
&input_source,
provider_url_index,
context.origin_entry.url(),
);
response_to_fetched_manifest(
response_result.response,
provider_url_index,
resolved_request_url,
context.origin_manifest_timeout_ms,
)
.await
}
Err(err) => Err(origin_manifest_fetch_error_from_io_error(&err)),
};
match fetch_result {
Ok(fetched) => return Ok(fetched.with_attempts(attempt_index + 1)),
Err(err) if is_hls_retryable_initial_manifest_fetch_error(&err) && attempt_index + 1 < attempts => {
let retry_after_ms = match &err {
OriginManifestFetchError::RetryableStatus(_, retry_after_ms) => *retry_after_ms,
_ => None,
};
let jitter_ms = if retry_after_ms.is_some() || context.retry_policy.jitter_max_ms == 0 {
0
} else {
fastrand::u64(0..=context.retry_policy.jitter_max_ms)
};
let delay_ms = next_retry_delay_ms(&context.retry_policy, attempt_index, retry_after_ms, jitter_ms);
log_manifest_retry_scheduled(
context,
ManifestRetryLogKind::InitialFetch,
attempt_index,
attempts,
delay_ms,
None,
Some(&err),
)
.await;
if delay_ms > 0 {
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
}
}
Err(err) => return Err(err),
}
}
Err(OriginManifestFetchError::RetryExhausted)
}
pub(crate) async fn score_hls_manifest_candidate_for_selection_log(
@@ -578,9 +678,10 @@ where
HlsManifestRecoveryAttemptError::Rejected(reason) if attempt_index + 1 < attempts => {
log_manifest_retry_scheduled(
context,
ManifestRetryLogKind::PinnedHostRecovery,
attempt_index,
attempts,
next_retry_delay_ms(&context.retry_policy, attempt_index, None),
next_retry_delay_ms(&context.retry_policy, attempt_index, None, 0),
Some(&reason),
None,
)
@@ -596,9 +697,10 @@ where
{
log_manifest_retry_scheduled(
context,
ManifestRetryLogKind::PinnedHostRecovery,
attempt_index,
attempts,
next_retry_delay_ms(&context.retry_policy, attempt_index, None),
next_retry_delay_ms(&context.retry_policy, attempt_index, None, 0),
None,
Some(&err),
)
@@ -1301,17 +1403,36 @@ pub(crate) fn fetched_effective_manifest_host(fetched: &FetchedOriginManifest) -
Url::parse(&fetched.resolved_request_url).ok().and_then(|url| url.host_str().map(str::to_string))
}
fn is_hls_retryable_manifest_reject_fetch_error(err: &OriginManifestFetchError) -> bool {
fn is_hls_retryable_initial_manifest_fetch_error(err: &OriginManifestFetchError) -> bool {
matches!(
err,
OriginManifestFetchError::RetryableStatus(_, _)
| OriginManifestFetchError::Request(_)
| OriginManifestFetchError::Redirect(_)
| OriginManifestFetchError::Timeout
| OriginManifestFetchError::RetryExhausted
| OriginManifestFetchError::ContentDecoding { .. }
| OriginManifestFetchError::ContentCoding(ContentCodingError::PrefixRead(_))
)
}
fn is_hls_retryable_manifest_reject_fetch_error(err: &OriginManifestFetchError) -> bool {
match err {
OriginManifestFetchError::RetryableStatus(_, _)
| OriginManifestFetchError::Request(_)
| OriginManifestFetchError::Redirect(_)
| OriginManifestFetchError::Timeout
| OriginManifestFetchError::RetryExhausted
| OriginManifestFetchError::ContentDecoding { .. }
| OriginManifestFetchError::ContentCoding(ContentCodingError::PrefixRead(_)) => true,
OriginManifestFetchError::PermanentStatus(_)
| OriginManifestFetchError::NonRetryableStatus(_)
| OriginManifestFetchError::ProviderUnavailable(_)
| OriginManifestFetchError::ContentCoding(_)
| OriginManifestFetchError::DecodedBodyLimitExceeded { .. }
| OriginManifestFetchError::InvalidUtf8 { .. } => false,
}
}
pub(crate) fn commit_error_to_fetch_error(err: &HlsManifestCommitError) -> OriginManifestFetchError {
match err {
HlsManifestCommitError::TimelineRejected { .. } | HlsManifestCommitError::RetryCurrentTarget => {
@@ -1347,12 +1468,25 @@ pub(crate) fn next_committed_origin_highwater(
}
}
fn next_retry_delay_ms(retry_policy: &RetryPolicy, attempt_index: usize, retry_after_ms: Option<u64>) -> u64 {
retry_after_ms.unwrap_or_else(|| retry_policy.delays_ms.get(attempt_index + 1).copied().unwrap_or_default())
fn next_retry_delay_ms(
retry_policy: &RetryPolicy,
attempt_index: usize,
retry_after_ms: Option<u64>,
jitter_ms: u64,
) -> u64 {
retry_after_ms
.unwrap_or_else(|| retry_policy.delay_for_attempt_ms(attempt_index + 1, jitter_ms).unwrap_or_default())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ManifestRetryLogKind {
InitialFetch,
PinnedHostRecovery,
}
async fn log_manifest_retry_scheduled(
context: &HlsOriginManifestFetchContext,
retry_kind: ManifestRetryLogKind,
attempt_index: usize,
attempts: usize,
delay_ms: u64,
@@ -1363,7 +1497,7 @@ async fn log_manifest_retry_scheduled(
let session = context.session.read().await;
safe_session_key(&session.key)
};
let status = manifest_retry_status_label(reject_reason, fetch_error);
let status = manifest_retry_status_label(retry_kind, reject_reason, fetch_error);
warn!(
"Manifest '{}' retry scheduled: status {} attempt {} of {} next_delay_ms={delay_ms}",
session_label,
@@ -1374,16 +1508,21 @@ async fn log_manifest_retry_scheduled(
}
fn manifest_retry_status_label(
retry_kind: ManifestRetryLogKind,
reject_reason: Option<&HlsManifestRejectLogReason>,
fetch_error: Option<&OriginManifestFetchError>,
) -> String {
match (reject_reason, fetch_error) {
(Some(reason), Some(err)) => {
format!("{} error={}", reason.status_label(), safe_origin_log_value(format!("{err:?}")))
match (retry_kind, reject_reason, fetch_error) {
(_, Some(reason), Some(err)) => format!("{} error={}", reason.status_label(), err.log_label()),
(_, Some(reason), None) => reason.status_label(),
(ManifestRetryLogKind::InitialFetch, None, Some(err)) => {
format!("initial-fetch error={}", err.log_label())
}
(Some(reason), None) => reason.status_label(),
(None, Some(err)) => format!("pinned-host-recovery error={}", safe_origin_log_value(format!("{err:?}"))),
(None, None) => "pinned-host-recovery".to_string(),
(ManifestRetryLogKind::InitialFetch, None, None) => "initial-fetch".to_string(),
(ManifestRetryLogKind::PinnedHostRecovery, None, Some(err)) => {
format!("pinned-host-recovery error={}", err.log_label())
}
(ManifestRetryLogKind::PinnedHostRecovery, None, None) => "pinned-host-recovery".to_string(),
}
}
@@ -1406,12 +1545,14 @@ async fn fetch_origin_manifest_once(
)
.await
} else {
let mut request_headers = headers.clone();
apply_outbound_content_coding_policy(&mut request_headers, OutboundContentCodingPolicy::Identity);
let response = client
.get(entry_url.clone())
.headers(headers.clone())
.headers(request_headers)
.send()
.await
.map_err(|err| origin_manifest_fetch_error_from_request_error(&err))?;
.map_err(|err| origin_manifest_fetch_error_from_reqwest_error(&err))?;
response_to_fetched_manifest(response, provider_url_index, entry_url.clone(), origin_manifest_timeout_ms).await
}
}
@@ -1428,12 +1569,14 @@ async fn fetch_origin_manifest_with_manual_redirects(
let mut remaining_redirects = MAX_MANUAL_REDIRECTS;
loop {
let mut request_headers = current_headers.clone();
apply_outbound_content_coding_policy(&mut request_headers, OutboundContentCodingPolicy::Identity);
let response = client
.get(current_url.clone())
.headers(current_headers.clone())
.headers(request_headers)
.send()
.await
.map_err(|err| origin_manifest_fetch_error_from_request_error(&err))?;
.map_err(|err| origin_manifest_fetch_error_from_reqwest_error(&err))?;
if !response.status().is_redirection() {
return response_to_fetched_manifest(
response,
@@ -1544,18 +1687,17 @@ async fn response_to_fetched_manifest(
);
match classify_origin_manifest_status(status) {
OriginManifestStatusClass::Success => {
let final_url = response.url().clone();
let redirect_host = hls_manifest_redirect_host(&resolved_request_url, &final_url);
let provider_session_headers = extract_hls_provider_session_header_map(response.headers());
let body = read_origin_manifest_body(response, origin_manifest_timeout_ms).await?;
let (decoded, body) = read_origin_manifest_body(response, origin_manifest_timeout_ms).await?;
let redirect_host = hls_manifest_redirect_host(&resolved_request_url, &decoded.final_url);
let provider_session_headers = extract_hls_provider_session_header_map(&decoded.headers);
Ok(FetchedOriginManifest {
body,
final_manifest_url: final_url.to_string(),
final_manifest_url: decoded.final_url.to_string(),
resolved_request_url: resolved_request_url.to_string(),
redirect_host,
provider_url_index,
provider_session_headers,
status,
status: decoded.status,
attempts: 1,
})
}
@@ -1567,37 +1709,66 @@ async fn response_to_fetched_manifest(
}
}
fn manifest_body_size_after_chunk(current: usize, chunk: usize) -> Option<usize> {
current.checked_add(chunk).filter(|size| *size <= MAX_HLS_MANIFEST_BYTES)
}
async fn read_origin_manifest_body(
response: reqwest::Response,
origin_manifest_timeout_ms: u64,
) -> Result<String, OriginManifestFetchError> {
if response.content_length().is_some_and(|size| size > MAX_HLS_MANIFEST_BYTES as u64) {
return Err(OriginManifestFetchError::NonRetryableStatus(StatusCode::PAYLOAD_TOO_LARGE));
}
) -> Result<(crate::utils::content_coding::DecodedHttpResponse, String), OriginManifestFetchError> {
let read = async move {
let capacity = response.content_length().map_or(0, |size| {
usize::try_from(size.min(MAX_HLS_MANIFEST_BYTES as u64)).unwrap_or(MAX_HLS_MANIFEST_BYTES)
});
let mut body = Vec::with_capacity(capacity);
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|err| origin_manifest_fetch_error_from_request_error(&err))?;
if manifest_body_size_after_chunk(body.len(), chunk.len()).is_none() {
return Err(OriginManifestFetchError::NonRetryableStatus(StatusCode::PAYLOAD_TOO_LARGE));
}
body.extend_from_slice(&chunk);
let mut decoded =
decode_response_to_identity(response, ContentCodingDetection::DeclaredOrKnownHlsManifestMagic)
.await
.map_err(origin_manifest_content_coding_error)?;
if let Some(observation) = decoded.content_coding_observation() {
log_hls_origin_content_coding(
observation,
HlsOriginContentCodingObjectKind::Manifest,
false,
HlsOriginContentCodingSource::Shared,
);
}
String::from_utf8(body).map_err(|_| OriginManifestFetchError::Request("origin manifest is not UTF-8".to_string()))
let body = read_utf8_limited(&mut decoded.body, MAX_HLS_MANIFEST_BYTES)
.await
.map_err(origin_manifest_body_read_error)?;
Ok((decoded, body))
};
timeout(Duration::from_millis(origin_manifest_timeout_ms.max(1)), read)
.await
.map_err(|_| OriginManifestFetchError::Timeout)?
}
fn origin_manifest_content_coding_error(error: ContentCodingError) -> OriginManifestFetchError {
match error {
ContentCodingError::PrefixRead(io_error) => {
if let Some(decoding_error) = content_decoding_error_from_io(&io_error) {
OriginManifestFetchError::ContentDecoding { coding: decoding_error.coding }
} else if io_error.kind() == io::ErrorKind::TimedOut {
OriginManifestFetchError::Timeout
} else if is_http_body_transport_error(&io_error) {
origin_manifest_fetch_error_from_io_error(&io_error)
} else {
OriginManifestFetchError::ContentCoding(ContentCodingError::PrefixRead(io_error))
}
}
error => OriginManifestFetchError::ContentCoding(error),
}
}
fn origin_manifest_body_read_error(error: ContentBodyReadError) -> OriginManifestFetchError {
match error {
ContentBodyReadError::LimitExceeded { limit } => OriginManifestFetchError::DecodedBodyLimitExceeded { limit },
ContentBodyReadError::InvalidUtf8 { valid_up_to, error_len } => {
OriginManifestFetchError::InvalidUtf8 { valid_up_to, error_len }
}
ContentBodyReadError::Io(io_error) => {
if let Some(decoding_error) = content_decoding_error_from_io(&io_error) {
OriginManifestFetchError::ContentDecoding { coding: decoding_error.coding }
} else {
origin_manifest_fetch_error_from_io_error(&io_error)
}
}
}
}
pub(crate) fn hls_manifest_redirect_host(resolved_request_url: &Url, final_url: &Url) -> Option<String> {
let final_host = final_url.host_str()?;
(resolved_request_url.host_str() != Some(final_host)).then(|| final_host.to_string())
@@ -1649,6 +1820,22 @@ fn origin_manifest_fetch_error_from_request_error(err: &impl ToString) -> Origin
}
}
fn origin_manifest_fetch_error_from_reqwest_error(err: &reqwest::Error) -> OriginManifestFetchError {
if err.is_timeout() {
OriginManifestFetchError::Timeout
} else {
origin_manifest_fetch_error_from_request_error(err)
}
}
fn origin_manifest_fetch_error_from_io_error(err: &io::Error) -> OriginManifestFetchError {
if err.kind() == io::ErrorKind::TimedOut {
OriginManifestFetchError::Timeout
} else {
origin_manifest_fetch_error_from_request_error(err)
}
}
fn request_failed_status_from_message(message: &str) -> Option<StatusCode> {
let marker = "Request failed (";
let status_start = message.find(marker)?.checked_add(marker.len())?;
@@ -1730,7 +1917,7 @@ pub(crate) async fn refresh_from_live_hls_entrypoint_with_retries(
log_origin_refresh_retry_scheduled(
origin_entry,
attempt_index,
next_retry_delay_ms(retry_policy, attempt_index, retry_after_ms),
next_retry_delay_ms(retry_policy, attempt_index, retry_after_ms, 0),
format!("status={}", status.as_u16()),
);
retry_after_delay_ms = retry_after_ms;
@@ -1742,8 +1929,22 @@ pub(crate) async fn refresh_from_live_hls_entrypoint_with_retries(
log_origin_refresh_retry_scheduled(
origin_entry,
attempt_index,
next_retry_delay_ms(retry_policy, attempt_index, None),
format!("error={}", safe_origin_log_value(&err)),
next_retry_delay_ms(retry_policy, attempt_index, None, 0),
"error=request".to_string(),
);
}
Ok(Err(
err @ (OriginManifestFetchError::ContentDecoding { .. }
| OriginManifestFetchError::ContentCoding(ContentCodingError::PrefixRead(_))),
)) => {
if attempt_index + 1 == attempts {
return Err(err);
}
log_origin_refresh_retry_scheduled(
origin_entry,
attempt_index,
next_retry_delay_ms(retry_policy, attempt_index, None, 0),
format!("error={}", err.log_label()),
);
}
Ok(Err(err @ (OriginManifestFetchError::Redirect(_) | OriginManifestFetchError::Timeout))) => {
@@ -1753,14 +1954,19 @@ pub(crate) async fn refresh_from_live_hls_entrypoint_with_retries(
log_origin_refresh_retry_scheduled(
origin_entry,
attempt_index,
next_retry_delay_ms(retry_policy, attempt_index, None),
format!("error={}", safe_origin_log_value(format!("{err:?}"))),
next_retry_delay_ms(retry_policy, attempt_index, None, 0),
format!("error={}", err.log_label()),
);
}
Ok(Err(OriginManifestFetchError::RetryExhausted)) => return Err(OriginManifestFetchError::RetryExhausted),
Ok(Err(OriginManifestFetchError::ProviderUnavailable(kind))) => {
return Err(OriginManifestFetchError::ProviderUnavailable(kind));
}
Ok(Err(
err @ (OriginManifestFetchError::ContentCoding(_)
| OriginManifestFetchError::DecodedBodyLimitExceeded { .. }
| OriginManifestFetchError::InvalidUtf8 { .. }),
)) => return Err(err),
Err(OriginManifestFetchError::Timeout) => {
if attempt_index + 1 == attempts {
return Err(OriginManifestFetchError::Timeout);
@@ -1768,7 +1974,7 @@ pub(crate) async fn refresh_from_live_hls_entrypoint_with_retries(
log_origin_refresh_retry_scheduled(
origin_entry,
attempt_index,
next_retry_delay_ms(retry_policy, attempt_index, None),
next_retry_delay_ms(retry_policy, attempt_index, None, 0),
"error=timeout",
);
}
@@ -1797,10 +2003,130 @@ fn log_origin_refresh_retry_scheduled(
#[cfg(test)]
mod tests {
use super::{
manifest_body_size_after_chunk, origin_manifest_fetch_error_from_request_error, request_failed_status_from_message,
OriginManifestFetchError, MAX_HLS_MANIFEST_BYTES,
fetch_origin_manifest_once, origin_manifest_content_coding_error, origin_manifest_fetch_error_from_io_error,
origin_manifest_fetch_error_from_request_error, request_failed_status_from_message, ManifestRetryLogKind,
OriginManifestFetchError, RetryPolicy, MAX_HLS_MANIFEST_BYTES,
};
use axum::http::StatusCode;
use crate::utils::content_coding::{ContentCoding, ContentCodingError};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use flate2::{
write::{DeflateEncoder, GzEncoder},
Compression,
};
use reqwest::{redirect::Policy, Client};
use std::{io, io::Write, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
sync::oneshot,
};
use url::Url;
const TEST_MANIFEST: &[u8] = b"#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXTINF:2,\nsegment.ts\n";
struct TestOriginResponse {
status: &'static str,
headers: Vec<(&'static str, String)>,
body: Vec<u8>,
body_delay: Duration,
}
impl TestOriginResponse {
fn ok(body: Vec<u8>) -> Self {
Self { status: "200 OK", headers: Vec::new(), body, body_delay: Duration::ZERO }
}
fn with_header(mut self, name: &'static str, value: impl Into<String>) -> Self {
self.headers.push((name, value.into()));
self
}
}
async fn spawn_test_origin(response: TestOriginResponse) -> (Url, oneshot::Receiver<String>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.expect("bind test origin");
let address = listener.local_addr().expect("test origin address");
let (request_sender, request_receiver) = oneshot::channel();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accept test request");
let mut request = Vec::new();
let mut buffer = [0_u8; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let count = socket.read(&mut buffer).await.expect("read test request");
if count == 0 {
break;
}
request.extend_from_slice(&buffer[..count]);
}
let _ = request_sender.send(String::from_utf8_lossy(&request).into_owned());
let mut response_head = format!(
"HTTP/1.1 {}\r\nConnection: close\r\nContent-Length: {}\r\n",
response.status,
response.body.len()
);
for (name, value) in response.headers {
response_head.push_str(name);
response_head.push_str(": ");
response_head.push_str(&value);
response_head.push_str("\r\n");
}
response_head.push_str("\r\n");
socket.write_all(response_head.as_bytes()).await.expect("write test response headers");
if !response.body_delay.is_zero() {
tokio::time::sleep(response.body_delay).await;
}
let _ = socket.write_all(&response.body).await;
});
(Url::parse(&format!("http://{address}/manifest.m3u8")).expect("test origin URL"), request_receiver)
}
fn gzip(input: &[u8]) -> Vec<u8> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(input).expect("gzip input");
encoder.finish().expect("finish gzip")
}
fn raw_deflate(input: &[u8]) -> Vec<u8> {
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
encoder.write_all(input).expect("raw-deflate input");
encoder.finish().expect("finish raw-deflate")
}
fn brotli(input: &[u8]) -> Vec<u8> {
let mut output = Vec::new();
{
let mut encoder = brotli::CompressorWriter::new(&mut output, 4096, 5, 22);
encoder.write_all(input).expect("brotli input");
}
output
}
async fn zstd(input: &[u8]) -> Vec<u8> {
let (writer, mut reader) = tokio::io::duplex(64 * 1024);
let input = input.to_vec();
let encoder_task = tokio::spawn(async move {
let mut encoder = async_compression::tokio::write::ZstdEncoder::new(writer);
encoder.write_all(&input).await.expect("zstd input");
encoder.shutdown().await.expect("finish zstd");
});
let mut output = Vec::new();
reader.read_to_end(&mut output).await.expect("read zstd output");
encoder_task.await.expect("join zstd encoder");
output
}
fn captured_header<'a>(request: &'a str, name: &str) -> Option<&'a str> {
request.lines().skip(1).find_map(|line| {
let (header_name, value) = line.split_once(':')?;
header_name.eq_ignore_ascii_case(name).then(|| value.trim())
})
}
fn test_clients() -> (Client, Client) {
let client = Client::builder().build().expect("test client");
let no_redirect_client = Client::builder().redirect(Policy::none()).build().expect("no-redirect client");
(client, no_redirect_client)
}
#[test]
fn request_failed_status_is_extracted_from_global_provider_policy_error() {
@@ -1812,10 +2138,43 @@ mod tests {
);
}
#[test]
fn initial_manifest_retry_delay_uses_next_slot_bounded_jitter_and_retry_after_override() {
let retry_policy = RetryPolicy { delays_ms: [0, 100, 250, 500, 750], jitter_max_ms: 25 };
assert_eq!(super::next_retry_delay_ms(&retry_policy, 0, None, 17), 117);
assert_eq!(super::next_retry_delay_ms(&retry_policy, 0, None, 100), 125);
assert_eq!(super::next_retry_delay_ms(&retry_policy, 1, None, 10), 260);
assert_eq!(super::next_retry_delay_ms(&retry_policy, 0, Some(3_000), 25), 3_000);
}
#[test]
fn initial_manifest_retry_status_is_not_labeled_as_recovery() {
let error = OriginManifestFetchError::Timeout;
let initial = super::manifest_retry_status_label(ManifestRetryLogKind::InitialFetch, None, Some(&error));
let recovery = super::manifest_retry_status_label(ManifestRetryLogKind::PinnedHostRecovery, None, Some(&error));
assert!(initial.starts_with("initial-fetch error="));
assert!(!initial.contains("pinned-host-recovery"));
assert!(recovery.starts_with("pinned-host-recovery error="));
}
#[test]
fn manifest_content_coding_log_labels_never_include_origin_controlled_details() {
let unsupported =
OriginManifestFetchError::ContentCoding(ContentCodingError::Unsupported("signed-token-secret".to_string()));
assert_eq!(unsupported.log_label(), "content_coding class=unsupported");
assert!(!unsupported.log_label().contains("signed-token-secret"));
let decoding = OriginManifestFetchError::ContentDecoding { coding: ContentCoding::Brotli };
assert_eq!(decoding.log_label(), "content_decoding coding=br");
}
#[test]
fn request_failed_407_maps_to_retryable_manifest_status() {
let err = origin_manifest_fetch_error_from_request_error(&
"Request failed (407 Proxy Authentication Required): provider://demo/live/u/p/1.m3u8",
let err = origin_manifest_fetch_error_from_request_error(
&"Request failed (407 Proxy Authentication Required): provider://demo/live/u/p/1.m3u8",
);
assert!(matches!(
err,
@@ -1839,20 +2198,211 @@ mod tests {
#[test]
fn request_failed_404_maps_to_permanent_manifest_status() {
let err =
origin_manifest_fetch_error_from_request_error(&"Request failed (404 Not Found): http://example.test/live.m3u8");
let err = origin_manifest_fetch_error_from_request_error(
&"Request failed (404 Not Found): http://example.test/live.m3u8",
);
assert!(matches!(err, OriginManifestFetchError::PermanentStatus(StatusCode::NOT_FOUND)));
}
#[test]
fn transport_error_without_http_status_stays_request_error() {
let err = origin_manifest_fetch_error_from_request_error(&"error sending request for url");
assert!(matches!(err, OriginManifestFetchError::Request(message) if message == "error sending request for url"));
assert!(
matches!(err, OriginManifestFetchError::Request(message) if message == "error sending request for url")
);
}
#[test]
fn manifest_body_limit_rejects_the_first_byte_above_the_limit() {
assert_eq!(manifest_body_size_after_chunk(MAX_HLS_MANIFEST_BYTES - 1, 1), Some(MAX_HLS_MANIFEST_BYTES));
assert_eq!(manifest_body_size_after_chunk(MAX_HLS_MANIFEST_BYTES, 1), None);
fn io_timeout_maps_to_structured_manifest_timeout() {
let error = io::Error::new(io::ErrorKind::TimedOut, "origin body timed out");
assert!(matches!(origin_manifest_fetch_error_from_io_error(&error), OriginManifestFetchError::Timeout));
let prefix_timeout =
ContentCodingError::PrefixRead(io::Error::new(io::ErrorKind::TimedOut, "origin prefix timed out"));
assert!(matches!(origin_manifest_content_coding_error(prefix_timeout), OriginManifestFetchError::Timeout));
}
#[tokio::test]
async fn shared_manifest_decodes_supported_origin_content_codings_and_keeps_provider_cookie() {
let encoded_bodies = [
("gzip", gzip(TEST_MANIFEST)),
("deflate", raw_deflate(TEST_MANIFEST)),
("br", brotli(TEST_MANIFEST)),
("zstd", zstd(TEST_MANIFEST).await),
];
for (coding, encoded_body) in encoded_bodies {
let response = TestOriginResponse::ok(encoded_body)
.with_header("Content-Encoding", coding)
.with_header("Set-Cookie", format!("sid={coding}; Path=/"));
let (entry_url, request_receiver) = spawn_test_origin(response).await;
let (client, no_redirect_client) = test_clients();
let mut headers = HeaderMap::new();
headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br"));
let fetched =
fetch_origin_manifest_once(&entry_url, &headers, &client, &no_redirect_client, false, None, 1_000)
.await
.unwrap_or_else(|error| panic!("decode {coding} manifest: {error:?}"));
assert_eq!(fetched.body.as_bytes(), TEST_MANIFEST, "coding={coding}");
assert_eq!(
fetched
.provider_session_headers
.get(header::COOKIE)
.expect("provider cookie")
.to_str()
.expect("provider cookie text"),
format!("sid={coding}"),
"coding={coding}"
);
let request = request_receiver.await.expect("captured request");
assert_eq!(captured_header(&request, "accept-encoding"), Some("identity"), "coding={coding}");
}
}
#[tokio::test]
async fn shared_manifest_magic_sniffs_gzip_only_in_manifest_mode() {
let (entry_url, _) = spawn_test_origin(TestOriginResponse::ok(gzip(TEST_MANIFEST))).await;
let (client, no_redirect_client) = test_clients();
let fetched =
fetch_origin_manifest_once(&entry_url, &HeaderMap::new(), &client, &no_redirect_client, false, None, 1_000)
.await
.expect("decode magic-sniffed gzip manifest");
assert_eq!(fetched.body.as_bytes(), TEST_MANIFEST);
}
#[tokio::test]
async fn direct_manual_redirect_reapplies_identity_after_cross_origin_scrubbing() {
let (target_url, target_request_receiver) =
spawn_test_origin(TestOriginResponse::ok(TEST_MANIFEST.to_vec())).await;
let redirect_response = TestOriginResponse {
status: "302 Found",
headers: vec![("Location", target_url.to_string())],
body: Vec::new(),
body_delay: Duration::ZERO,
};
let (entry_url, redirect_request_receiver) = spawn_test_origin(redirect_response).await;
let (client, no_redirect_client) = test_clients();
let mut headers = HeaderMap::new();
headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br"));
headers.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer secret"));
headers.insert(header::COOKIE, HeaderValue::from_static("sid=secret"));
let fetched = fetch_origin_manifest_once(&entry_url, &headers, &client, &no_redirect_client, true, None, 1_000)
.await
.expect("follow manual manifest redirect");
assert_eq!(fetched.body.as_bytes(), TEST_MANIFEST);
let redirect_request = redirect_request_receiver.await.expect("captured redirect request");
let target_request = target_request_receiver.await.expect("captured target request");
assert_eq!(captured_header(&redirect_request, "accept-encoding"), Some("identity"));
assert_eq!(captured_header(&target_request, "accept-encoding"), Some("identity"));
assert!(captured_header(&target_request, "authorization").is_none());
assert!(captured_header(&target_request, "cookie").is_none());
}
#[tokio::test]
async fn shared_manifest_limit_applies_after_decompression() {
let decoded_body = vec![b'x'; MAX_HLS_MANIFEST_BYTES + 1];
let response = TestOriginResponse::ok(gzip(&decoded_body)).with_header("Content-Encoding", "gzip");
let (entry_url, _) = spawn_test_origin(response).await;
let (client, no_redirect_client) = test_clients();
let error =
fetch_origin_manifest_once(&entry_url, &HeaderMap::new(), &client, &no_redirect_client, false, None, 2_000)
.await
.expect_err("decoded manifest above limit must fail");
assert!(matches!(
error,
OriginManifestFetchError::DecodedBodyLimitExceeded { limit } if limit == MAX_HLS_MANIFEST_BYTES
));
}
#[tokio::test]
async fn shared_manifest_deadline_includes_magic_prefix_read() {
let response = TestOriginResponse {
body_delay: Duration::from_millis(100),
..TestOriginResponse::ok(TEST_MANIFEST.to_vec())
};
let (entry_url, _) = spawn_test_origin(response).await;
let (client, no_redirect_client) = test_clients();
let error =
fetch_origin_manifest_once(&entry_url, &HeaderMap::new(), &client, &no_redirect_client, false, None, 10)
.await
.expect_err("prefix read must honor manifest deadline");
assert!(matches!(error, OriginManifestFetchError::Timeout));
}
#[tokio::test]
async fn shared_manifest_encoded_body_timeout_stays_structured_timeout() {
let response = TestOriginResponse {
body_delay: Duration::from_millis(100),
..TestOriginResponse::ok(gzip(TEST_MANIFEST)).with_header("Content-Encoding", "gzip")
};
let (entry_url, _) = spawn_test_origin(response).await;
let (client, no_redirect_client) = test_clients();
let error =
fetch_origin_manifest_once(&entry_url, &HeaderMap::new(), &client, &no_redirect_client, false, None, 10)
.await
.expect_err("encoded body read must honor manifest deadline");
assert!(matches!(error, OriginManifestFetchError::Timeout));
}
#[tokio::test]
async fn shared_manifest_distinguishes_invalid_utf8_from_decoder_failure() {
let (invalid_utf8_url, _) = spawn_test_origin(TestOriginResponse::ok(vec![0xff])).await;
let corrupt_gzip = TestOriginResponse::ok(vec![0x1f, 0x8b, 0x08, 0x00]).with_header("Content-Encoding", "gzip");
let (corrupt_gzip_url, _) = spawn_test_origin(corrupt_gzip).await;
let (client, no_redirect_client) = test_clients();
let invalid_utf8 = fetch_origin_manifest_once(
&invalid_utf8_url,
&HeaderMap::new(),
&client,
&no_redirect_client,
false,
None,
1_000,
)
.await
.expect_err("invalid UTF-8 must fail");
let decoder_failure = fetch_origin_manifest_once(
&corrupt_gzip_url,
&HeaderMap::new(),
&client,
&no_redirect_client,
false,
None,
1_000,
)
.await
.expect_err("corrupt gzip must fail");
assert!(matches!(invalid_utf8, OriginManifestFetchError::InvalidUtf8 { .. }));
assert!(matches!(decoder_failure, OriginManifestFetchError::ContentDecoding { .. }));
}
#[tokio::test]
async fn shared_manifest_rejects_encoded_partial_content() {
let response = TestOriginResponse {
status: "206 Partial Content",
..TestOriginResponse::ok(gzip(TEST_MANIFEST)).with_header("Content-Encoding", "gzip")
};
let (entry_url, _) = spawn_test_origin(response).await;
let (client, no_redirect_client) = test_clients();
let error =
fetch_origin_manifest_once(&entry_url, &HeaderMap::new(), &client, &no_redirect_client, false, None, 1_000)
.await
.expect_err("encoded partial manifest must fail");
assert!(matches!(error, OriginManifestFetchError::ContentCoding(ContentCodingError::EncodedPartialContent)));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use super::{cache::MapCacheKey, ids::ProxySessionId, session::HlsSession, timeline::CacheAccessState};
use axum::http::StatusCode;
use crate::processing::parser::hls::origin_manifest::ParsedByteRange;
use axum::http::StatusCode;
use std::{
fmt,
hash::{Hash, Hasher},
+115 -44
View File
@@ -2,20 +2,19 @@ use super::{
begin_hls_origin_account_io_bounded, build_hls_origin_resource_headers, finish_hls_origin_account_io,
hls_object_body_deadline, run_hls_origin_resource_retry_loop_with_attempt_prepare, CachedSegmentMetadata,
HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseStore, HlsBoundAccountAcquireErrorKind,
HlsOriginAccountIoLeaseGuard, HlsOriginByteRangeExpectation, HlsOriginIoContext, HlsOriginResourceClients,
HlsOriginResourceFetchError, HlsOriginResourceFetchTarget, HlsResourceFetchKind, HlsResourceFetchSource,
HlsSegmentCache,
HlsSessionHandle, MapCacheKey, MapCacheStatus, OriginMapFetchRef, ProxyMapId, SegmentFetchPolicy,
HlsOriginAccountIoLeaseGuard, HlsOriginByteRangeExpectation, HlsOriginIoContext, HlsOriginResourceBodyDeadline,
HlsOriginResourceClients, HlsOriginResourceFetchError, HlsOriginResourceFetchTarget, HlsResourceFetchKind,
HlsResourceFetchSource, HlsSegmentCache, HlsSessionHandle, MapCacheKey, MapCacheStatus, OriginMapFetchRef,
ProxyMapId, SegmentFetchPolicy,
};
use crate::processing::parser::hls::origin_manifest::ParsedByteRange;
use crate::{processing::parser::hls::origin_manifest::ParsedByteRange, utils::content_coding::DecodedHttpResponse};
use arc_swap::ArcSwap;
use axum::http::HeaderMap;
use futures::{FutureExt, TryStreamExt};
use futures::FutureExt;
use log::debug;
use reqwest::Client;
use std::{fmt, io, sync::Arc};
use std::{fmt, sync::Arc};
use tokio::sync::{OwnedSemaphorePermit, RwLock, Semaphore};
use tokio_util::io::StreamReader;
/// Shared context required to schedule EXT-X-MAP origin fetches without holding session locks.
#[derive(Clone)]
@@ -174,12 +173,7 @@ impl HlsMapWorkerPool {
proxy_map_id_log
);
Some(MapFetchSnapshot {
proxy_map_id,
proxy_map_id_log,
cache_key,
fetch_ref,
})
Some(MapFetchSnapshot { proxy_map_id, proxy_map_id_log, cache_key, fetch_ref })
}
async fn fetch_one_map(
@@ -216,8 +210,10 @@ impl HlsMapWorkerPool {
response_flag_reason = Some(HlsAccessLeaseChannelUnavailableReason::MapPermanentFailure {
status: err.permanent_status(),
});
entry.status =
MapCacheStatus::FailedPermanent { failed_at_ms: finished_at_ms, status: err.permanent_status() };
entry.status = MapCacheStatus::FailedPermanent {
failed_at_ms: finished_at_ms,
status: err.permanent_status(),
};
}
}
}
@@ -248,10 +244,7 @@ impl HlsMapWorkerPool {
reason: HlsAccessLeaseChannelUnavailableReason,
) -> usize {
let proxy_session_id = session.read().await.proxy_session_id.clone();
self.access_leases
.write()
.await
.mark_channel_unavailable_for_session(&proxy_session_id, now_ms, reason)
self.access_leases.write().await.mark_channel_unavailable_for_session(&proxy_session_id, now_ms, reason)
}
fn schedule_wake(self: &Arc<Self>, context: MapFetchContext, now_ms: u64) {
@@ -396,7 +389,6 @@ async fn fetch_map_with_retries_into_cache(
let session_log_id = context.session.read().await.proxy_session_id.0.clone();
let context = context.clone();
let snapshot = snapshot.clone();
let commit_policy = policy.clone();
let policy_for_prepare = policy.clone();
let prepare_context = context.clone();
let cleanup_context = context.clone();
@@ -417,12 +409,11 @@ async fn fetch_map_with_retries_into_cache(
}
.boxed()
},
move |response, _attempt, guard| {
move |response, _attempt, body_deadline, guard| {
let context = context.clone();
let snapshot = snapshot.clone();
let policy = commit_policy.clone();
async move {
let commit_result = commit_map_response_into_cache(&context, &snapshot, &policy, response).await;
let commit_result = commit_map_response_into_cache(&context, &snapshot, response, body_deadline).await;
let origin_work = finish_map_origin_attempt(context, guard).await;
commit_result.map(|metadata| MapFetchCommit {
content_length: metadata.size,
@@ -438,24 +429,14 @@ async fn fetch_map_with_retries_into_cache(
async fn commit_map_response_into_cache(
context: &MapFetchContext,
snapshot: &MapFetchSnapshot,
policy: &SegmentFetchPolicy,
response: reqwest::Response,
response: DecodedHttpResponse,
body_deadline: HlsOriginResourceBodyDeadline,
) -> Result<CachedSegmentMetadata, MapFetchError> {
let deadline = hls_object_body_deadline(policy.origin_segment_timeout_ms);
let stream_reader = StreamReader::new(response.bytes_stream().map_err(io::Error::other));
context
.segment_cache
.write_temp_and_commit_with_timeout(&snapshot.cache_key, stream_reader, deadline)
.write_temp_and_commit_with_deadline(&snapshot.cache_key, response.body, body_deadline.deadline())
.await
.map_err(
|err| {
if err.kind() == io::ErrorKind::TimedOut {
MapFetchError::Timeout
} else {
MapFetchError::cache_commit(&err)
}
},
)
.map_err(|err| MapFetchError::cache_body(&err))
}
fn build_map_origin_headers(
@@ -481,7 +462,8 @@ mod tests {
},
};
use axum::http::{header, HeaderMap};
use std::{sync::Arc, time::Duration};
use flate2::{write::GzEncoder, Compression};
use std::{io::Write, sync::Arc, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
@@ -526,7 +508,7 @@ mod tests {
&HeaderMap::new(),
Some(ParsedByteRange { offset: 10, length: 5 }),
)
.expect("headers should build");
.expect("headers should build");
assert_eq!(headers.get(header::RANGE).expect("range"), "bytes=10-14");
assert_eq!(headers.get(header::ACCEPT_ENCODING).expect("encoding"), "identity");
@@ -609,6 +591,97 @@ mod tests {
server.await.expect("server joins");
}
#[tokio::test]
async fn gzip_fmp4_map_fetch_commits_identity_representation() {
const FMP4_MAP: &[u8] = b"\0\0\0\x18ftypisom\0\0\x02\0isomiso2";
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(FMP4_MAP).expect("gzip fixture should encode");
let encoded_map = encoder.finish().expect("gzip fixture should finish");
let encoded_len = encoded_map.len();
let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds");
let addr = listener.local_addr().expect("local addr");
let server = tokio::spawn(async move {
let Ok((mut socket, _)) = listener.accept().await else {
return;
};
let mut buf = vec![0_u8; 2048];
let read = socket.read(&mut buf).await.expect("request reads");
let request = String::from_utf8_lossy(&buf[..read]);
assert!(
request.to_ascii_lowercase().contains("accept-encoding: identity"),
"map origin request must enforce identity"
);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: video/mp4\r\nContent-Encoding: gzip\r\nContent-Length: {encoded_len}\r\nConnection: close\r\n\r\n"
);
socket.write_all(headers.as_bytes()).await.expect("response headers write");
socket.write_all(&encoded_map).await.expect("response body writes");
});
let base_url = format!("http://{addr}/live/index.m3u8");
let manifest = normal_manifest("#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:4.0,\n1.m4s\n", &base_url);
let store = HlsSessionStore::new();
let session = store.get_or_create_session(HlsSessionKey::new(1, "1"), b"secret", 0).await;
{
let mut session = session.write().await;
session.apply_origin_manifest(&manifest).expect("manifest maps");
session.queue_map_fetch_candidates(1);
}
let temp_dir = tempfile::tempdir().expect("tempdir");
let cache = Arc::new(HlsSegmentCache::with_cache_path(temp_dir.path().to_path_buf()));
let worker = Arc::new(HlsMapWorkerPool::new(SegmentFetchPolicy {
retry_jitter_max_ms: 0,
origin_segment_timeout_ms: 1_000,
..SegmentFetchPolicy::default()
}));
let proxy_session_id = session.read().await.proxy_session_id.clone();
grant_usable_map_access_lease(&worker, &proxy_session_id).await;
worker
.wake_scheduler(
MapFetchContext {
session: Arc::clone(&session),
segment_cache: Arc::clone(&cache),
headers: HeaderMap::new(),
origin_provider_session_headers: HeaderMap::new(),
client: reqwest::Client::new(),
no_redirect_client: reqwest::Client::new(),
use_manual_redirects: false,
origin_io: None,
},
2,
)
.await;
for _ in 0..50 {
if matches!(
session.read().await.maps.values().next().map(|map| &map.status),
Some(MapCacheStatus::Ready { .. })
) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let cache_key = {
let session = session.read().await;
let map = session.maps.values().next().expect("map");
assert!(matches!(
&map.status,
MapCacheStatus::Ready { content_length, .. }
if *content_length == u64::try_from(FMP4_MAP.len()).expect("fixture length fits u64")
));
map.cache_key.clone()
};
let metadata = cache.metadata(&cache_key).await.expect("metadata reads").expect("map is committed");
let cached_map = tokio::fs::read(metadata.path).await.expect("cached map reads");
assert_eq!(cached_map, FMP4_MAP);
assert!(!cached_map.starts_with(&[0x1f, 0x8b]));
assert!(!cache.has_active_temp_files().await);
server.await.expect("server joins");
}
#[tokio::test]
async fn map_fetch_snapshot_uses_concrete_final_map_fetch_url() {
let manifest = normal_manifest(
@@ -638,10 +711,8 @@ mod tests {
origin_io: None,
};
let snapshot = worker
.next_fetch_snapshot(&context, 2, &SegmentFetchPolicy::default())
.await
.expect("map snapshot");
let snapshot =
worker.next_fetch_snapshot(&context, 2, &SegmentFetchPolicy::default()).await.expect("map snapshot");
assert_eq!(snapshot.fetch_ref.resolved_origin_url, "https://cdn.example.net/live/redirected/init.mp4");
assert_eq!(snapshot.fetch_ref.byte_range, Some(ParsedByteRange { length: 5, offset: 10 }));
+28 -32
View File
@@ -43,9 +43,9 @@ mod headers;
mod ids;
mod lease;
mod lifecycle;
mod manager;
mod manifest_commit;
mod manifest_fetch;
mod manager;
mod map;
mod map_fetcher;
mod observability;
@@ -55,8 +55,8 @@ mod playback;
mod prefetch;
mod qos;
mod refresh;
mod resource_fetch;
mod renderer;
mod resource_fetch;
mod response;
mod segment_fetcher;
mod segment_repair;
@@ -87,22 +87,18 @@ pub use self::{
ids::{build_proxy_session_id, HlsSessionKey, ProxySessionId},
lease::{
new_hls_access_lease_id, HlsAccessLease, HlsAccessLeaseActivation, HlsAccessLeaseChannelUnavailableReason,
HlsAccessLeaseId, HlsAccessLeaseIdleRelease, HlsAccessLeaseLifecycleSnapshot,
HlsAccessLeasePendingDeadline, HlsAccessLeaseResponseFlag, HlsAccessLeaseSessionSnapshot,
HlsAccessLeaseState, HlsAccessLeaseStore, HlsAccessLeaseTiming, HlsAccessLeaseTouch,
HlsFreshManifestRequiredReason, HlsPlaybackFamilyKey,
HlsAccessLeaseId, HlsAccessLeaseIdleRelease, HlsAccessLeaseLifecycleSnapshot, HlsAccessLeasePendingDeadline,
HlsAccessLeaseResponseFlag, HlsAccessLeaseSessionSnapshot, HlsAccessLeaseState, HlsAccessLeaseStore,
HlsAccessLeaseTiming, HlsAccessLeaseTouch, HlsFreshManifestRequiredReason, HlsPlaybackFamilyKey,
},
lifecycle::{HlsLifecycleEvent, HlsLifecycleEventKey, HlsLifecycleManager},
manager::{exec_hls_lifecycle, HlsProxyManager},
manifest_commit::{
hls_cached_manifest_options_for_requirement, hls_committed_manifest_body_for_request,
hls_manifest_commit_requirement, hls_should_wait_for_initial_manifest_commit, HlsCachedManifestOptions,
HlsCommittedManifestBody,
},
manifest_fetch::{
classify_origin_manifest_status, LiveHlsOriginEntry, OriginManifestFetchError, OriginManifestStatusClass,
RetryPolicy,
},
manager::{exec_hls_lifecycle, HlsProxyManager},
manifest_fetch::{classify_origin_manifest_status, LiveHlsOriginEntry, OriginManifestStatusClass, RetryPolicy},
map::{MapCacheStatus, MapEntry, OriginMapFetchRef, OriginMapKey, ProxyMapId},
map_fetcher::{HlsMapWorkerPool, MapFetchContext},
observability::{
@@ -130,21 +126,17 @@ pub use self::{
cold_start_retry_after_seconds, maybe_trigger_origin_refresh, trigger_origin_refresh_sync,
HlsManifestCommitRequirement, OriginRefreshRequest, OriginRefreshState,
},
resource_fetch::{
build_hls_origin_resource_headers, build_hls_origin_resource_headers_with_client_range,
classify_hls_resource_status, fetch_hls_origin_resource_response, log_hls_resource_attempt_started,
log_hls_resource_attempt_succeeded, log_hls_resource_fetch_failed, log_hls_resource_retry_scheduled,
log_hls_resource_timeout, retry_after_secs_from_ms, run_hls_origin_resource_retry_loop,
run_hls_origin_resource_retry_loop_with_attempt_prepare, HlsOriginByteRangeExpectation,
HlsOriginResourceAttemptCleanupFuture, HlsOriginResourceAttemptPrepareFuture, HlsOriginResourceClients,
HlsOriginResourceCommitFuture, HlsOriginResourceFetchError, HlsOriginResourceFetchTarget,
HlsResourceFetchAttempt, HlsResourceFetchKind, HlsResourceFetchLogContext, HlsResourceFetchLogStatus,
HlsResourceFetchSource, HlsResourceStatusClass,
},
renderer::{
renderer_candidate_window_proxy_seqs, HlsManifestRenderer, RenderError, RenderPolicy, RenderedManifest,
RenderedManifestStoreOutcome, RenderedManifestStoreRejectReason,
},
resource_fetch::{
build_hls_origin_resource_headers, build_hls_origin_resource_headers_with_client_range,
retry_after_secs_from_ms, run_hls_origin_resource_retry_loop_with_attempt_prepare,
HlsOriginByteRangeExpectation, HlsOriginResourceBodyDeadline, HlsOriginResourceClients,
HlsOriginResourceFetchError, HlsOriginResourceFetchTarget, HlsResourceFetchAttempt, HlsResourceFetchKind,
HlsResourceFetchSource,
},
response::{
serve_hls_map_cache_outcome, serve_hls_map_cache_response, serve_hls_segment_cache_outcome,
serve_hls_segment_cache_response, serve_hls_transient_object_cache_outcome,
@@ -163,14 +155,12 @@ pub use self::{
TransientPassthroughReason,
},
session_store::{
HlsExpiredSessionMarker, HlsExpiredSessionReason, HlsSessionHandle, HlsSessionStore,
HlsSessionStoreOutcome,
HlsExpiredSessionMarker, HlsExpiredSessionReason, HlsSessionHandle, HlsSessionStore, HlsSessionStoreOutcome,
},
timeline::{
default_content_type_for_segment_ext, CacheAccessState, OriginSegmentFetchRef, OriginSegmentKey,
is_hls_provisioning_gap_segment, is_hls_provisioning_segment, SegmentCacheStatus, SegmentEntry,
TimelineMapError, HLS_PROVISIONING_GAP_ORIGIN_EPOCH, HLS_PROVISIONING_ORIGIN_EPOCH,
HLS_PROVISIONING_SEGMENT_DURATION_MS,
default_content_type_for_segment_ext, is_hls_provisioning_gap_segment, is_hls_provisioning_segment,
CacheAccessState, OriginSegmentFetchRef, OriginSegmentKey, SegmentCacheStatus, SegmentEntry, TimelineMapError,
HLS_PROVISIONING_GAP_ORIGIN_EPOCH, HLS_PROVISIONING_ORIGIN_EPOCH, HLS_PROVISIONING_SEGMENT_DURATION_MS,
HLS_PROVISIONING_TARGET_DURATION_SECS,
},
transient::{
@@ -183,9 +173,15 @@ pub use self::{
fetch_and_commit_hls_transient_origin_response_with_attempt_prepare,
fetch_hls_transient_origin_response_with_attempt_prepare, hls_transient_object_fetch_failure,
hls_transient_origin_response, hls_transient_resource_fetch_kind,
is_hls_transient_full_object_cacheable_request, resolve_hls_transient_object_cache_action,
HlsTransientCacheCommitContext, HlsTransientObjectCacheAction, HlsTransientObjectCacheResolution,
HlsTransientObjectFetchFailure, HlsTransientObjectFetchFinalizer, HlsTransientOriginCacheFetchRequest,
HlsTransientOriginFetchRequest, HlsTransientOriginIoGuard,
is_hls_transient_full_object_cacheable_request, record_successful_transient_segment_fetch,
record_temporary_transient_segment_fetch_failure, resolve_hls_transient_object_cache_action,
HlsTransientCacheCommitContext, HlsTransientDecodedOriginResponse, HlsTransientDirectResponseContext,
HlsTransientObjectCacheAction, HlsTransientObjectCacheResolution, HlsTransientObjectFetchFailure,
HlsTransientObjectFetchFinalizer, HlsTransientOriginCacheFetchRequest, HlsTransientOriginFetchRequest,
HlsTransientOriginIoGuard,
},
};
pub(crate) use self::{
manifest_fetch::MAX_HLS_MANIFEST_BYTES,
observability::{log_hls_origin_content_coding, HlsOriginContentCodingObjectKind, HlsOriginContentCodingSource},
};
@@ -1,8 +1,80 @@
use super::{HlsAccessLeaseId, HlsSessionKey, ProxySessionId};
use crate::utils::content_coding::ContentCodingObservation;
use log::debug;
use sha2::{Digest, Sha256};
use shared::utils::sanitize_sensitive_info;
use std::sync::atomic::{AtomicU64, Ordering};
/// Fixed HLS object classes allowed in content-coding diagnostics.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) enum HlsOriginContentCodingObjectKind {
Manifest,
Segment,
Map,
Key,
Part,
Other,
}
impl HlsOriginContentCodingObjectKind {
const fn as_log_value(self) -> &'static str {
match self {
Self::Manifest => "manifest",
Self::Segment => "segment",
Self::Map => "map",
Self::Key => "key",
Self::Part => "part",
Self::Other => "other",
}
}
}
/// Fixed Tuliprox HLS stacks allowed in content-coding diagnostics.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) enum HlsOriginContentCodingSource {
Legacy,
Shared,
}
impl HlsOriginContentCodingSource {
const fn as_log_value(self) -> &'static str {
match self {
Self::Legacy => "legacy",
Self::Shared => "shared",
}
}
}
/// Logs a prepared origin-coding normalization using fixed or numeric fields only.
pub(crate) fn log_hls_origin_content_coding(
observation: ContentCodingObservation,
object_kind: HlsOriginContentCodingObjectKind,
range_requested: bool,
source: HlsOriginContentCodingSource,
) {
debug!(
"HLS origin content coding normalization prepared: {}",
hls_origin_content_coding_log_fields(observation, object_kind, range_requested, source)
);
}
fn hls_origin_content_coding_log_fields(
observation: ContentCodingObservation,
object_kind: HlsOriginContentCodingObjectKind,
range_requested: bool,
source: HlsOriginContentCodingSource,
) -> String {
let content_length = observation.content_length.map_or_else(|| "unknown".to_string(), |value| value.to_string());
format!(
"object_kind={} content_encoding={} status={} requested_accept_encoding=identity decoded_to_identity=true content_length={} range_requested={} source={}",
object_kind.as_log_value(),
observation.content_encoding,
observation.status.as_u16(),
content_length,
range_requested,
source.as_log_value()
)
}
/// In-memory counters for the live HLS cache path.
#[derive(Debug, Default)]
pub struct HlsCacheMetrics {
@@ -120,7 +192,7 @@ pub fn safe_user_session_token(session_token: &str) -> String { short_hash(sessi
pub fn safe_proxy_session_id(proxy_session_id: &ProxySessionId) -> String { short_hash(&proxy_session_id.0) }
pub fn safe_origin_log_value(value: impl AsRef<str>) -> String { sanitize_sensitive_info(value.as_ref()).into_owned() }
pub fn safe_origin_log_value(value: impl AsRef<str>) -> String { format!("origin#{}", short_hash(value.as_ref())) }
fn increment(counter: &AtomicU64, count: u64) { counter.fetch_add(count, Ordering::Relaxed); }
@@ -134,12 +206,20 @@ fn short_hash(value: &str) -> String {
#[cfg(test)]
mod tests {
use super::{safe_origin_log_value, safe_proxy_session_id, safe_session_key, HlsCacheMetrics};
use crate::api::model::{HlsSessionKey, ProxySessionId};
use super::{
hls_origin_content_coding_log_fields, safe_origin_log_value, safe_proxy_session_id, safe_session_key,
HlsCacheMetrics, HlsOriginContentCodingObjectKind, HlsOriginContentCodingSource,
};
use crate::{
api::model::{HlsSessionKey, ProxySessionId},
utils::content_coding::ContentCodingObservation,
};
use reqwest::StatusCode;
#[test]
fn safe_log_helpers_do_not_emit_credentials_or_full_proxy_session_id() {
let sanitized = safe_origin_log_value("http://user:password@example.com/live/user/password/123.ts");
assert!(sanitized.starts_with("origin#"));
assert!(!sanitized.contains("user:password"));
assert!(!sanitized.contains("/user/password/"));
@@ -171,4 +251,47 @@ mod tests {
assert_eq!(snapshot.prefetch_queued, 2);
assert_eq!(snapshot.prefetch_skipped, 3);
}
#[test]
fn content_coding_log_fields_are_fixed_redacted_and_complete() {
let fields = hls_origin_content_coding_log_fields(
ContentCodingObservation {
content_encoding: "multiple",
status: StatusCode::OK,
content_length: Some(321),
},
HlsOriginContentCodingObjectKind::Manifest,
false,
HlsOriginContentCodingSource::Legacy,
);
assert_eq!(
fields,
"object_kind=manifest content_encoding=multiple status=200 requested_accept_encoding=identity decoded_to_identity=true content_length=321 range_requested=false source=legacy"
);
for sensitive_marker in ["http", "?", "cookie", "manifest-body", "signed-token"] {
assert!(!fields.contains(sensitive_marker));
}
let unknown_length = hls_origin_content_coding_log_fields(
ContentCodingObservation {
content_encoding: "gzip",
status: StatusCode::PARTIAL_CONTENT,
content_length: None,
},
HlsOriginContentCodingObjectKind::Segment,
true,
HlsOriginContentCodingSource::Shared,
);
assert!(unknown_length.contains("content_length=unknown"));
assert!(unknown_length.contains("range_requested=true source=shared"));
let part = hls_origin_content_coding_log_fields(
ContentCodingObservation { content_encoding: "br", status: StatusCode::OK, content_length: Some(17) },
HlsOriginContentCodingObjectKind::Part,
false,
HlsOriginContentCodingSource::Shared,
);
assert!(part.starts_with("object_kind=part content_encoding=br status=200 "));
}
}
+28 -10
View File
@@ -5,8 +5,12 @@ use crate::{
};
use log::debug;
use shared::utils::sanitize_sensitive_info;
use std::{fmt, net::SocketAddr, sync::Arc};
use std::time::{Duration, Instant};
use std::{
fmt,
net::SocketAddr,
sync::Arc,
time::{Duration, Instant},
};
use tokio::{
sync::{Mutex, Notify},
time::timeout,
@@ -16,15 +20,26 @@ const HLS_ACCOUNT_OVERLAP_FALLBACK_TARGET_DURATION_MS: u64 = 15_000;
const HLS_ORIGIN_ACCOUNT_IO_WAIT_RECHECK: Duration = Duration::from_millis(25);
/// Stable source metadata for one shared live-HLS session.
///
/// Session identity is derived only from `input_id` and the immutable input
/// origin ID in `stream_ref`; target IDs, virtual IDs, and resolved origin URLs
/// are deliberately excluded.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct HlsOriginSource {
/// Internal ID of the configured Tuliprox input.
pub input_id: u16,
/// Input name used to resolve the current origin account or alias.
pub input_name: Arc<str>,
/// Exact, non-empty origin/provider stream ID captured before target mapping.
pub stream_ref: String,
pub source_kind: HlsOriginSourceKind,
}
impl HlsOriginSource {
/// Creates source metadata from an immutable input-stream identity.
///
/// `stream_ref` must be the origin/provider ID, never a target-specific or
/// virtual ID.
pub fn new(
input_id: u16,
input_name: Arc<str>,
@@ -129,11 +144,7 @@ impl HlsAccountOverlapTiming {
}
pub fn reservation_ttl_secs(self) -> u64 {
self.hard_active_window_ms
.saturating_add(self.soft_active_window_ms)
.saturating_add(999)
/ 1_000
+ 1
self.hard_active_window_ms.saturating_add(self.soft_active_window_ms).saturating_add(999) / 1_000 + 1
}
}
@@ -616,9 +627,7 @@ async fn reserve_hls_origin_account_io_slot(
if now >= deadline {
return Err(HlsBoundAccountAcquireErrorKind::WaitTimedOut);
}
let wait_for = deadline
.saturating_duration_since(now)
.min(HLS_ORIGIN_ACCOUNT_IO_WAIT_RECHECK);
let wait_for = deadline.saturating_duration_since(now).min(HLS_ORIGIN_ACCOUNT_IO_WAIT_RECHECK);
tokio::select! {
() = notify.notified() => {}
() = tokio::time::sleep(wait_for) => {}
@@ -846,6 +855,15 @@ mod tests {
assert_eq!(direct.session_key().stable_value(), "input:7|hls|80510");
}
#[test]
fn origin_source_preserves_alphanumeric_input_stream_id() {
let source =
HlsOriginSource::new(7, Arc::from("input"), "m3u-channel_A42", HlsOriginSourceKind::M3uMediaPlaylist);
assert_eq!(source.stream_ref, "m3u-channel_A42");
assert_eq!(source.session_key().stable_value(), "input:7|hls|m3u-channel_A42");
}
#[test]
fn account_overlap_timing_uses_target_duration_or_fallback() {
let timing = HlsAccountOverlapTiming::from_target_duration_secs(Some(12));
+4 -17
View File
@@ -222,8 +222,7 @@ impl HlsSession {
return report;
}
let tail_index =
known_sequences.len().saturating_sub(1).saturating_sub(self.initial_prefetch_gap_segments);
let tail_index = known_sequences.len().saturating_sub(1).saturating_sub(self.initial_prefetch_gap_segments);
let render_window_len = known_sequences.len().min(6).min(tail_index.saturating_add(1));
let render_start_index = tail_index.saturating_add(1).saturating_sub(render_window_len);
@@ -260,13 +259,7 @@ impl HlsSession {
pub fn configure_segment_prefetch_queue(&mut self, max_prefetch_depth: usize) {
for proxy_seq in self.segment_prefetch_queue.set_max_prefetch_depth(max_prefetch_depth) {
if let Some(entry) = self.segments.get_mut(&proxy_seq) {
if matches!(
entry.status,
SegmentCacheStatus::Queued {
priority: SegmentFetchPriority::Prefetch,
..
}
) {
if matches!(entry.status, SegmentCacheStatus::Queued { priority: SegmentFetchPriority::Prefetch, .. }) {
entry.status = SegmentCacheStatus::Discovered;
}
}
@@ -395,13 +388,7 @@ mod tests {
session.configure_segment_prefetch_queue(1);
assert!(matches!(
session.segments.get(&4).expect("trimmed segment").status,
SegmentCacheStatus::Discovered
));
assert!(matches!(
session.segments.get(&5).expect("trimmed segment").status,
SegmentCacheStatus::Discovered
));
assert!(matches!(session.segments.get(&4).expect("trimmed segment").status, SegmentCacheStatus::Discovered));
assert!(matches!(session.segments.get(&5).expect("trimmed segment").status, SegmentCacheStatus::Discovered));
}
}
+11 -25
View File
@@ -1,8 +1,5 @@
use super::{HlsAccessLeaseId, ProxySessionId};
use crate::{
api::model::StreamMeterHandle,
model::AppConfig,
};
use crate::{api::model::StreamMeterHandle, model::AppConfig};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::RwLock;
@@ -18,9 +15,7 @@ impl HlsQosRuntimeConfig {
let Some(reverse_proxy) = config.reverse_proxy.as_ref() else {
return Self::default();
};
Self {
live_metering_enabled: reverse_proxy.stream.as_ref().is_some_and(|stream| stream.metrics_enabled),
}
Self { live_metering_enabled: reverse_proxy.stream.as_ref().is_some_and(|stream| stream.metrics_enabled) }
}
}
@@ -44,13 +39,12 @@ struct HlsAccessLeaseQosState {
}
impl HlsAccessLeaseQosState {
fn registration(&self, emit_connect_record: bool, register_meter: Option<Arc<StreamMeterHandle>>) -> HlsQosRegistration {
HlsQosRegistration {
meter_uid: self.meter_uid,
meter: self.meter.clone(),
register_meter,
emit_connect_record,
}
fn registration(
&self,
emit_connect_record: bool,
register_meter: Option<Arc<StreamMeterHandle>>,
) -> HlsQosRegistration {
HlsQosRegistration { meter_uid: self.meter_uid, meter: self.meter.clone(), register_meter, emit_connect_record }
}
}
@@ -74,11 +68,7 @@ impl HlsQosRegistry {
let (meter_uid, meter) = meter_init.map_or((0, None), |init| (init.meter_uid, Some(init.meter)));
let register_meter = meter.clone();
let state = HlsAccessLeaseQosState {
proxy_session_id: proxy_session_id.clone(),
meter_uid,
meter,
};
let state = HlsAccessLeaseQosState { proxy_session_id: proxy_session_id.clone(), meter_uid, meter };
let registration = state.registration(true, register_meter);
states.insert(lease_id.clone(), state);
registration
@@ -94,10 +84,7 @@ impl HlsQosRegistry {
pub async fn remove_access_leases(&self, lease_ids: &[HlsAccessLeaseId]) -> usize {
let mut states = self.states.write().await;
lease_ids
.iter()
.filter(|lease_id| states.remove(*lease_id).is_some())
.count()
lease_ids.iter().filter(|lease_id| states.remove(*lease_id).is_some()).count()
}
pub async fn remove_proxy_session_state(&self, proxy_session_id: &ProxySessionId) -> usize {
@@ -122,8 +109,7 @@ impl HlsQosRegistry {
#[cfg(test)]
mod tests {
use super::{HlsQosMeterInit, HlsQosRegistry};
use crate::api::model::{EventManager, StreamMeterHandle};
use crate::api::model::{HlsAccessLeaseId, ProxySessionId};
use crate::api::model::{EventManager, HlsAccessLeaseId, ProxySessionId, StreamMeterHandle};
use std::sync::Arc;
#[tokio::test]
+432 -57
View File
@@ -1,6 +1,6 @@
use super::{
begin_hls_origin_account_io, finish_hls_origin_account_io, is_hls_provisioning_gap_segment,
is_hls_provisioning_segment,
begin_hls_origin_account_io, finish_hls_origin_account_io, hls_origin_headers_with_provider_session,
is_hls_provisioning_gap_segment, is_hls_provisioning_segment,
manifest_fetch::{
commit_error_to_fetch_error, evaluate_manifest_origin_quality_with_mode, fetch_hls_origin_manifest_request,
fetched_effective_manifest_host, log_hls_manifest_initial_selected, manifest_host_switch_failure_threshold,
@@ -12,12 +12,11 @@ use super::{
OriginManifestFetchError, RetryPolicy,
},
safe_hls_access_lease_id, safe_origin_log_value, safe_proxy_session_id, safe_session_key,
hls_origin_headers_with_provider_session, sanitized_hls_origin_headers,
HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseId, HlsFreshManifestRequiredReason, HlsManifestRenderer,
HlsManifestTemporaryFailureKind, HlsManifestTemporaryFailureTransition, HlsMapWorkerPool, HlsOriginIoContext,
HlsOriginWorkClass, HlsProxyManager, HlsSegmentCache, HlsSegmentRepairManager, HlsSegmentWorkerPool, HlsSessionHandle,
HlsSessionMode, MapFetchContext, RenderedManifestStoreOutcome, RenderedManifestStoreRejectReason,
SegmentFetchContext, TransientPassthroughReason,
sanitized_hls_origin_headers, HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseId,
HlsFreshManifestRequiredReason, HlsManifestRenderer, HlsManifestTemporaryFailureKind,
HlsManifestTemporaryFailureTransition, HlsMapWorkerPool, HlsOriginIoContext, HlsOriginWorkClass, HlsProxyManager,
HlsSegmentCache, HlsSegmentRepairManager, HlsSegmentWorkerPool, HlsSessionHandle, HlsSessionMode, MapFetchContext,
RenderedManifestStoreOutcome, RenderedManifestStoreRejectReason, SegmentFetchContext, TransientPassthroughReason,
};
use crate::{
model::{AppConfig, HlsManifestRecoveryBurstConfig, ReverseProxyDisabledHeaderConfig, StripConfig},
@@ -38,8 +37,7 @@ use crate::{
use axum::http::HeaderMap;
use log::{debug, info, warn};
use reqwest::Client;
use shared::model::HlsStripMode;
use shared::utils::sanitize_sensitive_info;
use shared::{model::HlsStripMode, utils::sanitize_sensitive_info};
use std::sync::Arc;
use url::Url;
@@ -404,7 +402,7 @@ async fn refresh_and_commit(mut request: OriginRefreshRequest, fetch_started_at_
"HLS origin manifest refresh completed: session={} proxy_session_id={} result=failed error={}",
safe_session_key(&session.key),
safe_proxy_session_id(&session.proxy_session_id),
safe_origin_log_value(format!("{err:?}"))
err.log_label()
);
(
false,
@@ -513,11 +511,7 @@ async fn mark_fresh_manifest_commit_failed_access_leases(
let marked = origin_io
.app_state
.hls_proxy
.mark_access_leases_channel_unavailable_for_session(
&proxy_session_id,
failed_at_ms,
unavailable_reason,
)
.mark_access_leases_channel_unavailable_for_session(&proxy_session_id, failed_at_ms, unavailable_reason)
.await;
if marked > 0 {
debug!(
@@ -574,19 +568,33 @@ fn manifest_temporary_failure_kind(err: &OriginManifestFetchError) -> Option<Hls
| OriginManifestFetchError::NonRetryableStatus(_)
| OriginManifestFetchError::Request(_)
| OriginManifestFetchError::Redirect(_)
| OriginManifestFetchError::ProviderUnavailable(_) => None,
| OriginManifestFetchError::ProviderUnavailable(_)
| OriginManifestFetchError::ContentCoding(_)
| OriginManifestFetchError::ContentDecoding { .. }
| OriginManifestFetchError::DecodedBodyLimitExceeded { .. }
| OriginManifestFetchError::InvalidUtf8 { .. } => None,
}
}
fn manifest_hard_fetch_error(err: &OriginManifestFetchError) -> bool {
match err {
OriginManifestFetchError::PermanentStatus(_) | OriginManifestFetchError::NonRetryableStatus(_) => true,
OriginManifestFetchError::PermanentStatus(_)
| OriginManifestFetchError::NonRetryableStatus(_)
| OriginManifestFetchError::ContentCoding(
crate::utils::content_coding::ContentCodingError::InvalidHeader
| crate::utils::content_coding::ContentCodingError::Unsupported(_)
| crate::utils::content_coding::ContentCodingError::EncodedPartialContent,
)
| OriginManifestFetchError::DecodedBodyLimitExceeded { .. }
| OriginManifestFetchError::InvalidUtf8 { .. } => true,
OriginManifestFetchError::ProviderUnavailable(kind) => !kind.is_retryable_resource_failure(),
OriginManifestFetchError::RetryableStatus(_, _)
| OriginManifestFetchError::RetryExhausted
| OriginManifestFetchError::Request(_)
| OriginManifestFetchError::Redirect(_)
| OriginManifestFetchError::Timeout => false,
| OriginManifestFetchError::Timeout
| OriginManifestFetchError::ContentCoding(crate::utils::content_coding::ContentCodingError::PrefixRead(_))
| OriginManifestFetchError::ContentDecoding { .. } => false,
}
}
@@ -661,7 +669,7 @@ async fn finish_refresh_failure(request: &OriginRefreshRequest, err: OriginManif
"HLS origin manifest refresh completed: session={} proxy_session_id={} result=failed error={}",
safe_session_key(&session.key),
safe_proxy_session_id(&session.proxy_session_id),
safe_origin_log_value(format!("{err:?}"))
err.log_label()
);
(request.manifest_commit_requirement.fresh_reason(), temporary_manifest_failure_reason)
};
@@ -731,9 +739,7 @@ async fn fetch_and_commit_manifest_with_policy(
) -> Result<CommittedOriginManifest, OriginManifestFetchError> {
let fetch_context = manifest_fetch_context(request);
let fetched =
fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&fetch_context))
.await?
.with_attempts(1);
fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&fetch_context)).await?;
let acceptance_mode = request.manifest_commit_requirement.acceptance_mode();
let selected_report =
score_hls_manifest_candidate_for_selection_log(&fetch_context, &fetched, acceptance_mode).await;
@@ -1057,10 +1063,7 @@ fn evaluate_manifest_acceptance(
}
}
fn mark_manifest_handoff_discontinuity_if_needed(
session: &mut super::HlsSession,
quality: &HlsManifestOriginQuality,
) {
fn mark_manifest_handoff_discontinuity_if_needed(session: &mut super::HlsSession, quality: &HlsManifestOriginQuality) {
if !quality.requires_handoff_discontinuity {
return;
}
@@ -1264,7 +1267,8 @@ fn commit_normal_manifest(
let provisioning_handoff = session.pending_handoff_discontinuity_sequence.is_some()
&& session.segments.values().any(is_hls_provisioning_segment);
let segment_durations = manifest.segments.iter().map(|segment| segment.duration_ms).collect::<Vec<_>>();
session.initial_prefetch_gap_segments = initial_hls_strip_segments_for_durations(&request.strip, &segment_durations);
session.initial_prefetch_gap_segments =
initial_hls_strip_segments_for_durations(&request.strip, &segment_durations);
session
.apply_origin_manifest(manifest)
.map_err(|err| HlsManifestCommitError::TimelineRejected { reason: HlsManifestRejectLogReason::from(err) })?;
@@ -1511,41 +1515,49 @@ fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into
mod tests {
use super::{
super::manifest_fetch::{
classify_origin_manifest_status, hls_manifest_redirect_host,
classify_origin_manifest_status, fetch_hls_origin_manifest_request, hls_manifest_redirect_host,
manifest_host_switch_failure_threshold_for_strip_segments, origin_highwater_policy_limit,
refresh_from_live_hls_entrypoint_with_retries, resolved_hls_manifest_request_url_from_input,
retry_after_delay_ms, retry_hls_origin_manifest_recovery_chain,
score_hls_manifest_recovery_candidate as score_manifest_recovery_candidate, FetchedOriginManifest,
HlsManifestCommitAcceptanceMode, HlsManifestCommitError, HlsManifestOriginQualityScore,
HlsManifestRejectLogReason, HlsManifestSequenceRelation, LiveHlsOriginEntry,
ManifestRecoverySelectionLogPhase, OriginManifestFetchError, OriginManifestStatusClass, RetryPolicy,
HlsManifestRejectLogReason, HlsManifestSequenceRelation, HlsOriginManifestFetchContext,
HlsOriginManifestFetchRequest, LiveHlsOriginEntry, ManifestRecoverySelectionLogPhase,
OriginManifestFetchError, OriginManifestStatusClass, RetryPolicy,
},
build_manifest_refresh_timing, commit_fetched_manifest, compute_origin_refresh_interval_ms,
fetched_effective_manifest_host, format_millis_as_seconds, format_optional_millis_as_seconds,
manifest_fetch_context, manifest_hard_fetch_error, manifest_progress_from_highwater, mark_origin_refresh_started,
manifest_temporary_failure_kind, record_pinned_host_recovery_chain_failed, request_error_indicates_timeout,
transient_reason_log_fields, HlsManifestAcceptanceDecision, HlsManifestCommitRequirement, HlsManifestProgress,
trigger_origin_refresh_sync, OriginRefreshRequest, OriginRefreshState,
manifest_fetch_context, manifest_hard_fetch_error, manifest_progress_from_highwater,
manifest_temporary_failure_kind, mark_origin_refresh_started, record_pinned_host_recovery_chain_failed,
request_error_indicates_timeout, transient_reason_log_fields, trigger_origin_refresh_sync,
HlsManifestAcceptanceDecision, HlsManifestCommitRequirement, HlsManifestProgress, OriginRefreshRequest,
OriginRefreshState,
};
use crate::{
api::model::{
maybe_trigger_origin_refresh, HlsAccessLease, HlsAccessLeaseId, HlsAccessLeasePendingDeadline,
HlsFreshManifestRequiredReason, HlsMapWorkerPool, HlsOriginAccountBinding, HlsPlaybackFamilyKey,
HlsProxyManager, HlsSegmentCache, HlsSegmentRepairManager, HlsSegmentWorkerPool, HlsSession,
HlsSessionKey, HlsSessionMode, HlsBoundAccountAcquireErrorKind, TimelineMapError,
TransientPassthroughReason,
HlsBoundAccountAcquireErrorKind, HlsFreshManifestRequiredReason, HlsMapWorkerPool, HlsOriginAccountBinding,
HlsPlaybackFamilyKey, HlsProxyManager, HlsSegmentCache, HlsSegmentRepairManager, HlsSegmentWorkerPool,
HlsSession, HlsSessionKey, HlsSessionMode, TimelineMapError, TransientPassthroughReason,
},
model::{
AppConfig, Config, ConfigProvider, HlsManifestRecoveryBurstConfig,
HlsSegmentRepairConfig, ReverseProxyDisabledHeaderConfig, SourcesConfig, StripConfig,
AppConfig, Config, ConfigProvider, HlsManifestRecoveryBurstConfig, HlsSegmentRepairConfig,
ReverseProxyDisabledHeaderConfig, SourcesConfig, StripConfig,
},
utils::content_coding::{ContentCoding, ContentCodingError},
};
use arc_swap::{ArcSwap, ArcSwapOption};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use shared::model::{ConfigPaths, ConfigProviderDto, HlsManifestRecoveryBurstLevel, HlsSegmentRepairMode, HlsStripMode, ProviderUrlSelectionPolicy};
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
use shared::model::{
ConfigPaths, ConfigProviderDto, HlsManifestRecoveryBurstLevel, HlsSegmentRepairMode, HlsStripMode,
ProviderUrlSelectionPolicy,
};
use std::{
io,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
@@ -1736,6 +1748,72 @@ mod tests {
)));
}
#[test]
fn manifest_content_failures_have_explicit_hard_and_temporary_classification() {
let errors = vec![
(
"invalid content-coding header",
OriginManifestFetchError::ContentCoding(ContentCodingError::InvalidHeader),
true,
),
(
"unsupported content coding",
OriginManifestFetchError::ContentCoding(ContentCodingError::Unsupported("unknown".to_string())),
true,
),
(
"encoded partial content",
OriginManifestFetchError::ContentCoding(ContentCodingError::EncodedPartialContent),
true,
),
(
"content-coding prefix read",
OriginManifestFetchError::ContentCoding(ContentCodingError::PrefixRead(io::Error::other(
"prefix read failed",
))),
false,
),
(
"gzip decoding",
OriginManifestFetchError::ContentDecoding { coding: ContentCoding::Gzip },
false,
),
(
"deflate decoding",
OriginManifestFetchError::ContentDecoding { coding: ContentCoding::Deflate },
false,
),
(
"brotli decoding",
OriginManifestFetchError::ContentDecoding { coding: ContentCoding::Brotli },
false,
),
(
"zstandard decoding",
OriginManifestFetchError::ContentDecoding { coding: ContentCoding::Zstd },
false,
),
(
"decoded body limit",
OriginManifestFetchError::DecodedBodyLimitExceeded { limit: 1024 },
true,
),
(
"invalid utf-8",
OriginManifestFetchError::InvalidUtf8 { valid_up_to: 7, error_len: Some(1) },
true,
),
];
for (label, error, expected_hard) in errors {
assert_eq!(manifest_hard_fetch_error(&error), expected_hard, "unexpected hard classification: {label}");
assert!(
manifest_temporary_failure_kind(&error).is_none(),
"content failure must not affect the temporary host-switch counter: {label}"
);
}
}
#[test]
fn request_error_timeout_detection_matches_global_helper_wording() {
assert!(request_error_indicates_timeout("Request timed out and no retries left"));
@@ -2125,9 +2203,8 @@ mod tests {
let session = test_session();
session.write().await.origin_refresh.next_fetch_allowed_at_ms = 10_000;
let mut request = test_origin_refresh_request(Arc::clone(&session));
request.manifest_commit_requirement = HlsManifestCommitRequirement::FreshCommitRequired {
reason: HlsFreshManifestRequiredReason::ColdStart,
};
request.manifest_commit_requirement =
HlsManifestCommitRequirement::FreshCommitRequired { reason: HlsFreshManifestRequiredReason::ColdStart };
assert!(mark_origin_refresh_started(&mut request, 1_000).await);
assert!(session.read().await.origin_refresh.in_flight);
@@ -2560,6 +2637,7 @@ mod tests {
struct TestOriginServer {
base_url: String,
requests: Arc<Mutex<Vec<String>>>,
raw_requests: Arc<Mutex<Vec<String>>>,
task: tokio::task::JoinHandle<()>,
}
@@ -2574,12 +2652,15 @@ mod tests {
let addr = listener.local_addr().expect("local addr");
let requests = Arc::new(Mutex::new(Vec::new()));
let requests_for_task = Arc::clone(&requests);
let raw_requests = Arc::new(Mutex::new(Vec::new()));
let raw_requests_for_task = Arc::clone(&raw_requests);
let task = tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
let requests = Arc::clone(&requests_for_task);
let raw_requests = Arc::clone(&raw_requests_for_task);
let handler = Arc::clone(&handler);
tokio::spawn(async move {
let mut buf = vec![0_u8; 4096];
@@ -2599,7 +2680,7 @@ mod tests {
return;
}
}
let request = String::from_utf8_lossy(&buf[..used]);
let request = String::from_utf8_lossy(&buf[..used]).into_owned();
let path = request
.lines()
.next()
@@ -2607,6 +2688,7 @@ mod tests {
.unwrap_or("/")
.to_string();
requests.lock().await.push(path.clone());
raw_requests.lock().await.push(request);
let (status, headers, body) = handler(path);
let reason = match status {
200 => "OK",
@@ -2632,7 +2714,14 @@ mod tests {
});
}
});
TestOriginServer { base_url: format!("http://{addr}"), requests, task }
TestOriginServer { base_url: format!("http://{addr}"), requests, raw_requests, task }
}
fn request_header_value<'a>(request: &'a str, expected_name: &str) -> Option<&'a str> {
request.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case(expected_name).then_some(value.trim())
})
}
fn no_delay_policy() -> RetryPolicy { RetryPolicy { delays_ms: [0, 0, 0, 0, 0], jitter_max_ms: 0 } }
@@ -2720,6 +2809,281 @@ mod tests {
fn manifest_body() -> String { "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXTINF:4.0,\nseg.ts\n".to_string() }
#[tokio::test]
async fn shared_initial_manifest_decoder_failure_retries_until_success() {
let hits = Arc::new(AtomicUsize::new(0));
let hits_for_handler = Arc::clone(&hits);
let server = spawn_test_origin(Arc::new(move |_path| {
if hits_for_handler.fetch_add(1, Ordering::SeqCst) < 2 {
return (200, vec![("Content-Encoding", "gzip".to_string())], "corrupt-gzip".to_string());
}
(200, Vec::new(), manifest_body())
}))
.await;
let origin_entry =
LiveHlsOriginEntry::parse(&format!("{}/live/user/pass/12345.m3u8", server.base_url)).expect("entry url");
let context = HlsOriginManifestFetchContext {
app_config: test_app_config(),
session: test_session(),
origin_entry,
headers: HeaderMap::new(),
client: reqwest::Client::new(),
no_redirect_client: reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("no-redirect client"),
use_manual_redirects: false,
origin_manifest_timeout_ms: 2_000,
manifest_recovery_burst: HlsManifestRecoveryBurstConfig::default(),
retry_policy: no_delay_policy(),
};
let fetched = fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&context))
.await
.expect("shared initial manifest should retry decoder failures");
assert_eq!(fetched.body, manifest_body());
assert_eq!(fetched.attempts, 3);
assert_eq!(server.requests.lock().await.len(), 3);
assert!(server
.raw_requests
.lock()
.await
.iter()
.all(|request| request_header_value(request, "accept-encoding") == Some("identity")));
}
#[tokio::test]
async fn shared_initial_manifest_waits_for_next_attempt_base_delay() {
let hits = Arc::new(AtomicUsize::new(0));
let hits_for_handler = Arc::clone(&hits);
let server = spawn_test_origin(Arc::new(move |_path| {
if hits_for_handler.fetch_add(1, Ordering::SeqCst) == 0 {
return (200, vec![("Content-Encoding", "gzip".to_string())], "corrupt-gzip".to_string());
}
(200, Vec::new(), manifest_body())
}))
.await;
let origin_entry =
LiveHlsOriginEntry::parse(&format!("{}/live/user/pass/12345.m3u8", server.base_url)).expect("entry url");
let context = HlsOriginManifestFetchContext {
app_config: test_app_config(),
session: test_session(),
origin_entry,
headers: HeaderMap::new(),
client: reqwest::Client::new(),
no_redirect_client: reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("no-redirect client"),
use_manual_redirects: false,
origin_manifest_timeout_ms: 2_000,
manifest_recovery_burst: HlsManifestRecoveryBurstConfig::default(),
retry_policy: RetryPolicy { delays_ms: [0, 100, 250, 500, 750], jitter_max_ms: 0 },
};
let started_at = std::time::Instant::now();
let fetched = fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&context))
.await
.expect("second logical attempt should succeed after its base delay");
assert_eq!(fetched.attempts, 2);
assert_eq!(server.requests.lock().await.len(), 2);
assert!(started_at.elapsed() >= std::time::Duration::from_millis(100));
}
#[tokio::test]
async fn shared_initial_manifest_automatic_cross_origin_redirect_keeps_identity_and_scrubs_credentials() {
let target = spawn_test_origin(Arc::new(|_path| (200, Vec::new(), manifest_body()))).await;
let target_url = format!("{}/final/manifest.m3u8", target.base_url);
let expected_target_url = target_url.clone();
let redirect =
spawn_test_origin(Arc::new(move |_path| (302, vec![("Location", target_url.clone())], String::new())))
.await;
let origin_entry =
LiveHlsOriginEntry::parse(&format!("{}/live/user/pass/12345.m3u8", redirect.base_url)).expect("entry url");
let mut headers = HeaderMap::new();
headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br"));
headers.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer origin-secret"));
headers.insert(header::COOKIE, HeaderValue::from_static("sid=origin-secret"));
let context = HlsOriginManifestFetchContext {
app_config: test_app_config(),
session: test_session(),
origin_entry,
headers,
client: reqwest::Client::builder().no_proxy().build().expect("client"),
no_redirect_client: reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("no-redirect client"),
use_manual_redirects: false,
origin_manifest_timeout_ms: 2_000,
manifest_recovery_burst: HlsManifestRecoveryBurstConfig::default(),
retry_policy: no_delay_policy(),
};
let fetched = fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&context))
.await
.expect("automatic redirect should return the decoded manifest");
assert_eq!(fetched.body, manifest_body());
assert_eq!(fetched.attempts, 1);
assert_eq!(fetched.final_manifest_url, expected_target_url);
let redirect_requests = redirect.raw_requests.lock().await;
let target_requests = target.raw_requests.lock().await;
assert_eq!(redirect_requests.len(), 1);
assert_eq!(target_requests.len(), 1);
assert_eq!(request_header_value(&redirect_requests[0], "accept-encoding"), Some("identity"));
assert_eq!(request_header_value(&target_requests[0], "accept-encoding"), Some("identity"));
assert_eq!(request_header_value(&redirect_requests[0], "authorization"), Some("Bearer origin-secret"));
assert_eq!(request_header_value(&redirect_requests[0], "cookie"), Some("sid=origin-secret"));
assert!(request_header_value(&target_requests[0], "authorization").is_none());
assert!(request_header_value(&target_requests[0], "cookie").is_none());
}
#[tokio::test]
async fn shared_initial_manifest_budget_covers_status_decoder_redirect_and_success() {
let target_hits = Arc::new(AtomicUsize::new(0));
let target_hits_for_handler = Arc::clone(&target_hits);
let server = spawn_test_origin(Arc::new(move |path| {
if path == "/live/user/pass/12345.m3u8" {
return (302, vec![("Location", "/live/play/once/12345".to_string())], String::new());
}
if path == "/live/play/once/12345" {
return match target_hits_for_handler.fetch_add(1, Ordering::SeqCst) {
0 => (500, Vec::new(), "temporary".to_string()),
1 => (200, vec![("Content-Encoding", "gzip".to_string())], "corrupt-gzip".to_string()),
_ => (200, Vec::new(), manifest_body()),
};
}
(404, Vec::new(), String::new())
}))
.await;
let origin_entry =
LiveHlsOriginEntry::parse(&format!("{}/live/user/pass/12345.m3u8", server.base_url)).expect("entry url");
let context = HlsOriginManifestFetchContext {
app_config: test_app_config(),
session: test_session(),
origin_entry,
headers: HeaderMap::new(),
client: reqwest::Client::new(),
no_redirect_client: reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("no-redirect client"),
use_manual_redirects: true,
origin_manifest_timeout_ms: 2_000,
manifest_recovery_burst: HlsManifestRecoveryBurstConfig::default(),
retry_policy: no_delay_policy(),
};
let fetched = fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&context))
.await
.expect("third logical attempt should succeed");
assert_eq!(fetched.body, manifest_body());
assert_eq!(fetched.attempts, 3);
assert_eq!(
*server.requests.lock().await,
vec![
"/live/user/pass/12345.m3u8",
"/live/play/once/12345",
"/live/user/pass/12345.m3u8",
"/live/play/once/12345",
"/live/user/pass/12345.m3u8",
"/live/play/once/12345",
]
);
assert!(server
.raw_requests
.lock()
.await
.iter()
.all(|request| request_header_value(request, "accept-encoding") == Some("identity")));
}
#[tokio::test]
async fn shared_initial_manifest_decoder_failures_stop_at_attempt_budget() {
let server = spawn_test_origin(Arc::new(|_path| {
(200, vec![("Content-Encoding", "gzip".to_string())], "corrupt-gzip".to_string())
}))
.await;
let origin_entry =
LiveHlsOriginEntry::parse(&format!("{}/live/user/pass/12345.m3u8", server.base_url)).expect("entry url");
let retry_policy = no_delay_policy();
let expected_attempts = retry_policy.attempt_count();
let context = HlsOriginManifestFetchContext {
app_config: test_app_config(),
session: test_session(),
origin_entry,
headers: HeaderMap::new(),
client: reqwest::Client::new(),
no_redirect_client: reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("no-redirect client"),
use_manual_redirects: false,
origin_manifest_timeout_ms: 2_000,
manifest_recovery_burst: HlsManifestRecoveryBurstConfig::default(),
retry_policy,
};
let error = fetch_hls_origin_manifest_request(HlsOriginManifestFetchRequest::initial_global_policy(&context))
.await
.expect_err("decoder failures must exhaust the logical attempt budget");
assert!(matches!(error, OriginManifestFetchError::ContentDecoding { .. }));
assert_eq!(server.requests.lock().await.len(), expected_attempts);
assert!(server
.raw_requests
.lock()
.await
.iter()
.all(|request| request_header_value(request, "accept-encoding") == Some("identity")));
}
#[tokio::test]
async fn shared_refresh_metrics_use_successful_manifest_attempt_count() {
let manifest_hits = Arc::new(AtomicUsize::new(0));
let manifest_hits_for_handler = Arc::clone(&manifest_hits);
let server = spawn_test_origin(Arc::new(move |path| {
if path != "/live/user/pass/12345.m3u8" {
return (404, Vec::new(), String::new());
}
if manifest_hits_for_handler.fetch_add(1, Ordering::SeqCst) < 2 {
return (200, vec![("Content-Encoding", "gzip".to_string())], "corrupt-gzip".to_string());
}
(200, Vec::new(), manifest_body())
}))
.await;
let session = test_session();
let mut request = test_origin_refresh_request(session);
request.origin_entry =
LiveHlsOriginEntry::parse(&format!("{}/live/user/pass/12345.m3u8", server.base_url)).expect("entry url");
let metrics = Arc::clone(request.segment_worker_pool.metrics());
assert!(trigger_origin_refresh_sync(request).await);
let snapshot = metrics.snapshot();
assert_eq!(snapshot.refresh_started, 1);
assert_eq!(snapshot.refresh_completed, 1);
assert_eq!(snapshot.refresh_retried, 2);
assert_eq!(snapshot.refresh_failed, 0);
let manifest_requests = server
.raw_requests
.lock()
.await
.iter()
.filter(|request| request.lines().next().is_some_and(|line| line.contains(" /live/user/pass/12345.m3u8 ")))
.cloned()
.collect::<Vec<_>>();
assert_eq!(manifest_requests.len(), 3);
assert!(manifest_requests
.iter()
.all(|request| request_header_value(request, "accept-encoding") == Some("identity")));
}
#[tokio::test]
async fn manifest_retry_starts_at_entrypoint_after_redirect_failure() {
let redirect_hits = Arc::new(AtomicUsize::new(0));
@@ -2879,14 +3243,26 @@ mod tests {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let first_requests = first.requests.lock().await;
let second_requests = second.requests.lock().await;
let first_manifest_requests =
first_requests.iter().filter(|path| path.as_str() == "/live/user/pass/12345.m3u8").count();
first.requests.lock().await.iter().filter(|path| path.as_str() == "/live/user/pass/12345.m3u8").count();
let second_manifest_requests =
second_requests.iter().filter(|path| path.as_str() == "/live/user/pass/12345.m3u8").count();
second.requests.lock().await.iter().filter(|path| path.as_str() == "/live/user/pass/12345.m3u8").count();
assert_eq!(first_manifest_requests, 1);
assert_eq!(second_manifest_requests, 1);
for server in [&first, &second] {
let manifest_requests = server
.raw_requests
.lock()
.await
.iter()
.filter(|request| {
request.lines().next().is_some_and(|line| line.contains(" /live/user/pass/12345.m3u8 "))
})
.cloned()
.collect::<Vec<_>>();
assert_eq!(manifest_requests.len(), 1);
assert_eq!(request_header_value(&manifest_requests[0], "accept-encoding"), Some("identity"));
}
let session = session.read().await;
assert_eq!(session.key.stable_value(), initial_session_key);
assert_eq!(session.proxy_session_id, initial_proxy_session_id);
@@ -3043,9 +3419,8 @@ mod tests {
score_manifest_recovery_candidate(&session, &same_host_next, &fetch_context).expect("score").quality.score,
HlsManifestOriginQualityScore::SameHostNextSequence
);
let other_host_score = score_manifest_recovery_candidate(&session, &other_host_next, &fetch_context)
.expect("score")
.quality;
let other_host_score =
score_manifest_recovery_candidate(&session, &other_host_next, &fetch_context).expect("score").quality;
assert_eq!(other_host_score.score, HlsManifestOriginQualityScore::OtherHostNextSequence);
assert!(other_host_score.requires_handoff_discontinuity);
}
+11 -23
View File
@@ -64,10 +64,7 @@ pub enum RenderedManifestStoreOutcome {
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum RenderedManifestStoreRejectReason {
RegressiveMediaSequence {
previous_first_proxy_seq: u64,
candidate_first_proxy_seq: u64,
},
RegressiveMediaSequence { previous_first_proxy_seq: u64, candidate_first_proxy_seq: u64 },
}
#[derive(Debug, Clone, Eq, PartialEq)]
@@ -164,10 +161,7 @@ fn select_window(session: &HlsSession, render_gap_segments: usize) -> Option<Vec
let current_origin_window_len = tail_seq.saturating_sub(head_seq).saturating_add(1);
let target_window_len = current_origin_window_len.min(u64::try_from(TARGET_VISIBLE_SEGMENTS).ok()?);
let start_seq = tail_seq
.saturating_add(1)
.saturating_sub(target_window_len)
.max(head_seq);
let start_seq = tail_seq.saturating_add(1).saturating_sub(target_window_len).max(head_seq);
let mut window = Vec::new();
let mut not_ready_count = 0_usize;
@@ -188,7 +182,8 @@ fn select_window(session: &HlsSession, render_gap_segments: usize) -> Option<Vec
if window.len() < MIN_VISIBLE_SEGMENTS {
return None;
}
if session.render_policy.initial_render_gap_segments == 0 && !window_contains_provisioning_segment(session, &window) {
if session.render_policy.initial_render_gap_segments == 0 && !window_contains_provisioning_segment(session, &window)
{
let target_duration_ms = u64::from(resolve_target_duration(session, &window)).saturating_mul(1_000);
if playlist_duration_ms(session, &window) < target_duration_ms.saturating_mul(3) {
return None;
@@ -220,8 +215,7 @@ fn render_window(
}
writeln!(body, "#EXT-X-TARGETDURATION:{target_duration}").map_err(|_| RenderError::InvalidState)?;
writeln!(body, "#EXT-X-MEDIA-SEQUENCE:{first_proxy_seq}").map_err(|_| RenderError::InvalidState)?;
writeln!(body, "#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuity_sequence}")
.map_err(|_| RenderError::InvalidState)?;
writeln!(body, "#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuity_sequence}").map_err(|_| RenderError::InvalidState)?;
let mut current_map_ref = None;
let contains_provisioning = window_contains_provisioning_segment(session, window);
@@ -236,8 +230,7 @@ fn render_window(
body.push('\n');
}
if let Some(program_date_time) = &entry.program_date_time {
writeln!(body, "#EXT-X-PROGRAM-DATE-TIME:{program_date_time}")
.map_err(|_| RenderError::InvalidState)?;
writeln!(body, "#EXT-X-PROGRAM-DATE-TIME:{program_date_time}").map_err(|_| RenderError::InvalidState)?;
}
if entry.discontinuity_before {
if contains_provisioning {
@@ -263,8 +256,7 @@ fn render_window(
}
current_map_ref = entry.map_ref;
}
writeln!(body, "#EXTINF:{},", format_duration_ms(entry.duration_ms))
.map_err(|_| RenderError::InvalidState)?;
writeln!(body, "#EXTINF:{},", format_duration_ms(entry.duration_ms)).map_err(|_| RenderError::InvalidState)?;
if is_local_provisioning_segment(entry) {
writeln!(
body,
@@ -345,10 +337,7 @@ fn resolve_target_duration(session: &HlsSession, window: &[u64]) -> u32 {
}
fn window_contains_provisioning_segment(session: &HlsSession, window: &[u64]) -> bool {
window
.iter()
.filter_map(|proxy_seq| session.segments.get(proxy_seq))
.any(is_local_provisioning_segment)
window.iter().filter_map(|proxy_seq| session.segments.get(proxy_seq)).any(is_local_provisioning_segment)
}
fn window_contains_origin_segment(session: &HlsSession, window: &[u64]) -> bool {
@@ -380,7 +369,6 @@ fn format_duration_ms(duration_ms: u64) -> String { format!("{}.{:03}", duration
#[cfg(test)]
mod tests {
use shared::model::HlsStripMode;
use super::{
HlsManifestRenderer, RenderError, RenderPolicy, RenderedManifest, RenderedManifestStoreOutcome,
RenderedManifestStoreRejectReason,
@@ -390,6 +378,7 @@ mod tests {
model::StripConfig,
processing::parser::hls::origin_manifest::{parse_origin_media_manifest, OriginManifestParseOutcome},
};
use shared::model::HlsStripMode;
const BASE_URL: &str = "http://origin.example.com/live/final/index.m3u8";
@@ -433,9 +422,8 @@ mod tests {
fn rendering_an_empty_internal_window_does_not_panic() {
let session = session();
let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
super::render_window(&session, &[], 0, 0)
}));
let rendered =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| super::render_window(&session, &[], 0, 0)));
assert!(rendered.is_ok());
}
File diff suppressed because it is too large Load Diff
+43 -52
View File
@@ -2,11 +2,10 @@
use super::{
hls_client_body_send_deadline, refresh_hls_client_body_send_deadline, safe_hls_access_lease_id,
safe_proxy_session_id, CacheAccessState,
HlsAccessLeaseId, HlsCacheMetrics, HlsMapFile, HlsProxyManager, HlsRepairRenderedObjectId, HlsSegmentCache,
HlsSegmentFile, HlsSegmentRepairManager, HlsSegmentRepairObjectContext, HlsSegmentRepairSource,
HlsSessionHandle, MapCacheKey, MapCacheStatus, ProxyMapId, SegmentCacheKey, SegmentCacheStatus,
TransientObjectCacheKey, TransientResourceFile, TransientResourceKind,
safe_proxy_session_id, CacheAccessState, HlsAccessLeaseId, HlsCacheMetrics, HlsMapFile, HlsProxyManager,
HlsRepairRenderedObjectId, HlsSegmentCache, HlsSegmentFile, HlsSegmentRepairManager, HlsSegmentRepairObjectContext,
HlsSegmentRepairSource, HlsSessionHandle, MapCacheKey, MapCacheStatus, ProxyMapId, SegmentCacheKey,
SegmentCacheStatus, TransientObjectCacheKey, TransientResourceFile, TransientResourceKind,
};
use crate::api::{api_utils::mark_response_as_uncompressed, model::StreamMeterHandle};
use arc_swap::ArcSwapOption;
@@ -19,8 +18,8 @@ use bytes::Bytes;
use futures::Stream;
use log::debug;
use std::{
io,
future::Future,
io,
pin::Pin,
sync::{
atomic::{AtomicU64, Ordering},
@@ -29,8 +28,10 @@ use std::{
task::{Context, Poll},
time::{Duration, Instant},
};
use tokio::io::AsyncReadExt;
use tokio::time::{sleep, Sleep};
use tokio::{
io::AsyncReadExt,
time::{sleep, Sleep},
};
use tokio_util::io::ReaderStream;
const ACCEPT_RANGES_VALUE: &str = "bytes";
@@ -123,9 +124,7 @@ impl HlsCacheResponseContext {
}
}
pub fn set_qos_meter(&self, qos_meter: Option<Arc<StreamMeterHandle>>) {
self.qos_meter.store(qos_meter);
}
pub fn set_qos_meter(&self, qos_meter: Option<Arc<StreamMeterHandle>>) { self.qos_meter.store(qos_meter); }
}
#[derive(Clone)]
@@ -196,15 +195,15 @@ pub async fn serve_hls_segment_cache_outcome(
context: &HlsCacheResponseContext,
) -> HlsResourceServeOutcome {
match lookup_segment_cache_object(&session, &segment_file, &context.hls_access_lease_id).await {
CacheObjectLookup::Ready(object) => {
HlsResourceServeOutcome::Ready(serve_cache_object(
CacheObjectLookup::Ready(object) => HlsResourceServeOutcome::Ready(
serve_cache_object(
segment_cache,
object,
range_header,
CacheObjectServeContext::from_response_context(context),
)
.await)
}
.await,
),
CacheObjectLookup::Failure(failure) => HlsResourceServeOutcome::Failure(failure),
}
}
@@ -232,15 +231,15 @@ pub async fn serve_hls_map_cache_outcome(
context: &HlsCacheResponseContext,
) -> HlsResourceServeOutcome {
match lookup_map_cache_object(&session, &map_file, &context.hls_access_lease_id).await {
CacheObjectLookup::Ready(object) => {
HlsResourceServeOutcome::Ready(serve_cache_object(
CacheObjectLookup::Ready(object) => HlsResourceServeOutcome::Ready(
serve_cache_object(
segment_cache,
object,
range_header,
CacheObjectServeContext::from_response_context(context),
)
.await)
}
.await,
),
CacheObjectLookup::Failure(failure) => HlsResourceServeOutcome::Failure(failure),
}
}
@@ -270,15 +269,15 @@ pub async fn serve_hls_transient_object_cache_outcome(
match lookup_transient_object_cache_object(&session, &resource_file, &context.hls_access_lease_id, context.now_ms)
.await
{
CacheObjectLookup::Ready(object) => {
HlsResourceServeOutcome::Ready(serve_cache_object(
CacheObjectLookup::Ready(object) => HlsResourceServeOutcome::Ready(
serve_cache_object(
segment_cache,
object,
range_header,
CacheObjectServeContext::from_response_context(context),
)
.await)
}
.await,
),
CacheObjectLookup::Failure(failure) => HlsResourceServeOutcome::Failure(failure),
}
}
@@ -305,10 +304,8 @@ where
{
let guard = CacheReadGuard::new(Arc::clone(&object.access), context.now_ms);
if let Some(repair_context) = object.repair_context.clone() {
if let Err(err) = context
.segment_repair
.repair_ready_cache_hit(&segment_cache, &object.key, repair_context)
.await
if let Err(err) =
context.segment_repair.repair_ready_cache_hit(&segment_cache, &object.key, repair_context).await
{
debug!(
"HLS segment repair skipped for ready cache hit: session={} resource={} error={err}",
@@ -425,9 +422,7 @@ async fn lookup_segment_cache_object(
}
match entry.status {
SegmentCacheStatus::Ready { .. } => {}
SegmentCacheStatus::Fetching { .. }
| SegmentCacheStatus::Queued { .. }
| SegmentCacheStatus::Discovered => {
SegmentCacheStatus::Fetching { .. } | SegmentCacheStatus::Queued { .. } | SegmentCacheStatus::Discovered => {
return CacheObjectLookup::Failure(HlsResourceServeFailure::TemporaryUnavailable {
retry_after_ms: NOT_READY_RETRY_AFTER_MS,
});
@@ -446,13 +441,13 @@ async fn lookup_segment_cache_object(
key: entry.cache_key.clone(),
access: Arc::clone(&entry.access),
content_type: entry.content_type.clone(),
log_context: CacheObjectLogContext {
lease: safe_hls_access_lease_id(hls_access_lease_id),
session: safe_proxy_session_id(&session.proxy_session_id),
resource_id: format!("{:06}", segment_file.proxy_seq),
object_kind: "Segment",
body_source: "normal",
},
log_context: CacheObjectLogContext {
lease: safe_hls_access_lease_id(hls_access_lease_id),
session: safe_proxy_session_id(&session.proxy_session_id),
resource_id: format!("{:06}", segment_file.proxy_seq),
object_kind: "Segment",
body_source: "normal",
},
repair_context: Some(HlsSegmentRepairObjectContext {
source: HlsSegmentRepairSource::Normal,
proxy_session_id: session.proxy_session_id.clone(),
@@ -492,9 +487,7 @@ async fn lookup_map_cache_object(
}
match entry.status {
MapCacheStatus::Ready { .. } => {}
MapCacheStatus::Fetching { .. }
| MapCacheStatus::Queued { .. }
| MapCacheStatus::Discovered => {
MapCacheStatus::Fetching { .. } | MapCacheStatus::Queued { .. } | MapCacheStatus::Discovered => {
return CacheObjectLookup::Failure(HlsResourceServeFailure::TemporaryUnavailable {
retry_after_ms: NOT_READY_RETRY_AFTER_MS,
});
@@ -620,11 +613,7 @@ fn service_unavailable_not_ready_response(retry_after_ms: u64) -> Response<Body>
let mut response = Response::new(Body::empty());
*response.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
let headers = response.headers_mut();
insert_header_value(
headers,
header::RETRY_AFTER,
&super::retry_after_secs_from_ms(retry_after_ms).to_string(),
);
insert_header_value(headers, header::RETRY_AFTER, &super::retry_after_secs_from_ms(retry_after_ms).to_string());
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
mark_response_as_uncompressed(&mut response);
response
@@ -777,10 +766,7 @@ impl Stream for ActiveReaderStream {
if self.send_deadline.as_mut().poll(cx).is_ready() {
self.finished = true;
self.log_completed("timeout");
return Poll::Ready(Some(Err(io::Error::new(
io::ErrorKind::TimedOut,
"hls client body send timed out",
))));
return Poll::Ready(Some(Err(io::Error::new(io::ErrorKind::TimedOut, "hls client body send timed out"))));
}
match self.inner.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(chunk))) => {
@@ -849,9 +835,13 @@ fn transient_body_object_kind(resource_kind: Option<TransientResourceKind>, exte
match resource_kind {
Some(TransientResourceKind::Key) => "Key",
Some(TransientResourceKind::Map) => "Map",
Some(TransientResourceKind::Segment | TransientResourceKind::Other) => "Segment",
Some(TransientResourceKind::Segment | TransientResourceKind::Part | TransientResourceKind::Other) => "Segment",
None => {
if extension.eq_ignore_ascii_case("key") { "Key" } else { "Segment" }
if extension.eq_ignore_ascii_case("key") {
"Key"
} else {
"Segment"
}
}
}
}
@@ -880,9 +870,9 @@ mod tests {
use axum::http::{header, HeaderValue, StatusCode};
use bytes::Bytes;
use http_body_util::BodyExt;
use shared::model::HlsSegmentRepairMode;
use std::{sync::Arc, time::Duration};
use tokio::sync::RwLock;
use shared::model::HlsSegmentRepairMode;
fn header(value: &str) -> HeaderValue { HeaderValue::from_str(value).expect("valid header") }
@@ -941,6 +931,7 @@ mod tests {
assert_eq!(transient_body_object_kind(Some(TransientResourceKind::Key), "bin"), "Key");
assert_eq!(transient_body_object_kind(Some(TransientResourceKind::Map), "bin"), "Map");
assert_eq!(transient_body_object_kind(Some(TransientResourceKind::Segment), "key"), "Segment");
assert_eq!(transient_body_object_kind(Some(TransientResourceKind::Part), "m4s"), "Segment");
assert_eq!(transient_body_object_kind(Some(TransientResourceKind::Other), "bin"), "Segment");
}
@@ -2,30 +2,30 @@
use super::{
begin_hls_origin_account_io_bounded, build_hls_origin_resource_headers, classify_hls_backpressure,
finish_hls_origin_account_io, hls_object_body_deadline,
run_hls_origin_resource_retry_loop_with_attempt_prepare, CachedSegmentMetadata,
HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseId, HlsAccessLeaseStore, HlsBackpressureState,
HlsBoundAccountAcquireErrorKind, HlsCacheMetrics, HlsOriginAccountIoLeaseGuard, HlsOriginByteRangeExpectation,
HlsOriginIoContext, HlsOriginResourceClients, HlsOriginResourceFetchError, HlsOriginResourceFetchTarget,
HlsRepairRenderedObjectId, HlsResourceFetchKind, HlsResourceFetchSource, HlsSegmentCache, HlsSegmentFailureObject,
HlsSegmentFailureTransition, HlsSegmentFile, HlsSegmentRepairManager, HlsSegmentRepairObjectContext,
HlsSegmentRepairSource, HlsSessionHandle, OriginSegmentFetchRef, SegmentCacheKey, SegmentCacheStatus,
SegmentFetchPriority,
finish_hls_origin_account_io, hls_object_body_deadline, run_hls_origin_resource_retry_loop_with_attempt_prepare,
CachedSegmentMetadata, HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseId, HlsAccessLeaseStore,
HlsBackpressureState, HlsBoundAccountAcquireErrorKind, HlsCacheMetrics, HlsOriginAccountIoLeaseGuard,
HlsOriginByteRangeExpectation, HlsOriginIoContext, HlsOriginResourceBodyDeadline, HlsOriginResourceClients,
HlsOriginResourceFetchError, HlsOriginResourceFetchTarget, HlsRepairRenderedObjectId, HlsResourceFetchKind,
HlsResourceFetchSource, HlsSegmentCache, HlsSegmentFailureObject, HlsSegmentFailureTransition, HlsSegmentFile,
HlsSegmentRepairManager, HlsSegmentRepairObjectContext, HlsSegmentRepairSource, HlsSessionHandle,
OriginSegmentFetchRef, SegmentCacheKey, SegmentCacheStatus, SegmentFetchPriority,
};
use crate::{
model::HlsCacheConfig, processing::parser::hls::origin_manifest::ParsedByteRange,
utils::content_coding::DecodedHttpResponse,
};
use crate::model::HlsCacheConfig;
use crate::processing::parser::hls::origin_manifest::ParsedByteRange;
use shared::model::{HlsSegmentRepairMode, HlsStripMode};
use arc_swap::ArcSwap;
use axum::http::HeaderMap;
use futures::{FutureExt, TryStreamExt};
use futures::FutureExt;
use log::{debug, warn};
use reqwest::Client;
use std::{fmt, io, sync::Arc, time::Duration};
use shared::model::{HlsSegmentRepairMode, HlsStripMode};
use std::{fmt, sync::Arc, time::Duration};
use tokio::{
sync::{OwnedSemaphorePermit, RwLock, Semaphore},
time::timeout,
};
use tokio_util::io::StreamReader;
const DEFAULT_MAX_GLOBAL_SEGMENT_FETCHES: usize = 64;
const DEFAULT_MAX_SESSION_SEGMENT_FETCHES: usize = 2;
@@ -483,14 +483,12 @@ impl HlsSegmentWorkerPool {
threshold
);
invalidate_queued_origin_work = true;
response_flag_reason = Some(HlsAccessLeaseChannelUnavailableReason::SegmentTemporaryFailureThreshold {
failures,
threshold,
});
SegmentCacheStatus::FailedPermanent {
failed_at_ms: finished_at_ms,
status: None,
}
response_flag_reason =
Some(HlsAccessLeaseChannelUnavailableReason::SegmentTemporaryFailureThreshold {
failures,
threshold,
});
SegmentCacheStatus::FailedPermanent { failed_at_ms: finished_at_ms, status: None }
}
}
} else {
@@ -508,10 +506,8 @@ impl HlsSegmentWorkerPool {
if invalidate_queued_origin_work {
session.invalidate_queued_origin_work();
if let Some(entry) = session.segments.get_mut(&snapshot.proxy_seq) {
entry.status = SegmentCacheStatus::FailedPermanent {
failed_at_ms: finished_at_ms,
status: None,
};
entry.status =
SegmentCacheStatus::FailedPermanent { failed_at_ms: finished_at_ms, status: None };
}
}
}
@@ -553,10 +549,7 @@ impl HlsSegmentWorkerPool {
reason: HlsAccessLeaseChannelUnavailableReason,
) -> usize {
let proxy_session_id = session.read().await.proxy_session_id.clone();
self.access_leases
.write()
.await
.mark_channel_unavailable_for_session(&proxy_session_id, now_ms, reason)
self.access_leases.write().await.mark_channel_unavailable_for_session(&proxy_session_id, now_ms, reason)
}
}
@@ -700,7 +693,6 @@ async fn fetch_segment_with_retries_into_cache(
let session_log_id = context.session.read().await.proxy_session_id.0.clone();
let context = context.clone();
let snapshot = snapshot.clone();
let commit_policy = policy.clone();
let policy_for_prepare = policy.clone();
let prepare_context = context.clone();
let cleanup_context = context.clone();
@@ -721,12 +713,12 @@ async fn fetch_segment_with_retries_into_cache(
}
.boxed()
},
move |response, _attempt, guard| {
move |response, _attempt, body_deadline, guard| {
let context = context.clone();
let snapshot = snapshot.clone();
let policy = commit_policy.clone();
async move {
let commit_result = commit_segment_response_into_cache(&context, &snapshot, &policy, response).await;
let commit_result =
commit_segment_response_into_cache(&context, &snapshot, response, body_deadline).await;
let origin_work = finish_segment_origin_attempt(context, guard).await;
commit_result.map(|metadata| SegmentFetchCommit {
content_length: metadata.size,
@@ -742,11 +734,9 @@ async fn fetch_segment_with_retries_into_cache(
async fn commit_segment_response_into_cache(
context: &SegmentFetchContext,
snapshot: &SegmentFetchSnapshot,
policy: &SegmentFetchPolicy,
response: reqwest::Response,
response: DecodedHttpResponse,
body_deadline: HlsOriginResourceBodyDeadline,
) -> Result<CachedSegmentMetadata, SegmentFetchError> {
let deadline = hls_object_body_deadline(policy.origin_segment_timeout_ms);
let stream_reader = StreamReader::new(response.bytes_stream().map_err(io::Error::other));
let proxy_session_id = context.session.read().await.proxy_session_id.clone();
let repair_context = HlsSegmentRepairObjectContext {
source: HlsSegmentRepairSource::Normal,
@@ -765,15 +755,15 @@ async fn commit_segment_response_into_cache(
};
context
.segment_repair
.commit_origin_response(&context.segment_cache, &snapshot.cache_key, stream_reader, deadline, repair_context)
.commit_origin_response(
&context.segment_cache,
&snapshot.cache_key,
response.body,
body_deadline.deadline(),
repair_context,
)
.await
.map_err(|err| {
if err.kind() == io::ErrorKind::TimedOut {
SegmentFetchError::Timeout
} else {
SegmentFetchError::cache_commit(&err)
}
})
.map_err(|err| SegmentFetchError::cache_body(&err))
}
fn build_segment_origin_headers(
@@ -798,9 +788,13 @@ mod tests {
model::HlsSegmentRepairConfig,
processing::parser::hls::origin_manifest::{parse_origin_media_manifest, OriginManifestParseOutcome},
};
use async_compression::tokio::bufread::{BrotliEncoder, DeflateEncoder, GzipEncoder, ZstdEncoder};
use axum::http::{header, HeaderMap, HeaderValue};
use shared::model::HlsSegmentRepairMode;
use std::{
collections::VecDeque,
io::Cursor,
path::{Path, PathBuf},
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
@@ -808,11 +802,10 @@ mod tests {
time::Duration,
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
io::{AsyncReadExt, AsyncWriteExt, BufReader},
net::TcpListener,
sync::Mutex,
};
use shared::model::HlsSegmentRepairMode;
const BASE_URL: &str = "http://origin.example.com/live/final/index.m3u8";
@@ -913,10 +906,8 @@ mod tests {
origin_io: None,
};
let snapshot = worker
.next_fetch_snapshot(&context, 11, &SegmentFetchPolicy::default())
.await
.expect("segment snapshot");
let snapshot =
worker.next_fetch_snapshot(&context, 11, &SegmentFetchPolicy::default()).await.expect("segment snapshot");
assert_eq!(snapshot.fetch_ref.resolved_origin_url, "https://cdn.example.net/live/redirected/media/seg001.ts");
assert_eq!(snapshot.fetch_ref.byte_range, None);
@@ -934,6 +925,128 @@ mod tests {
fn drop(&mut self) { self.task.abort(); }
}
#[derive(Clone)]
struct TestOriginResponse {
status: u16,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
impl TestOriginResponse {
fn encoded(content_encoding: &str, body: Vec<u8>) -> Self {
Self { status: 200, headers: vec![("Content-Encoding".to_string(), content_encoding.to_string())], body }
}
}
#[derive(Debug, Clone, Copy)]
enum TestContentEncoding {
Gzip,
RawDeflate,
Brotli,
Zstd,
}
impl TestContentEncoding {
const fn header_value(self) -> &'static str {
match self {
Self::Gzip => "gzip",
Self::RawDeflate => "deflate",
Self::Brotli => "br",
Self::Zstd => "zstd",
}
}
}
async fn encode_test_body(body: &[u8], encoding: TestContentEncoding) -> Vec<u8> {
let reader = BufReader::new(Cursor::new(body.to_vec()));
let mut encoded = Vec::new();
match encoding {
TestContentEncoding::Gzip => GzipEncoder::new(reader).read_to_end(&mut encoded).await,
TestContentEncoding::RawDeflate => DeflateEncoder::new(reader).read_to_end(&mut encoded).await,
TestContentEncoding::Brotli => BrotliEncoder::new(reader).read_to_end(&mut encoded).await,
TestContentEncoding::Zstd => ZstdEncoder::new(reader).read_to_end(&mut encoded).await,
}
.expect("test body encodes");
encoded
}
async fn spawn_sequence_response_server(responses: Vec<TestOriginResponse>) -> TestSegmentServer {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds");
let addr = listener.local_addr().expect("local addr");
let active = Arc::new(AtomicUsize::new(0));
let max_active = Arc::new(AtomicUsize::new(0));
let requests = Arc::new(Mutex::new(Vec::new()));
let responses = Arc::new(Mutex::new(VecDeque::from(responses)));
let task_active = Arc::clone(&active);
let task_max = Arc::clone(&max_active);
let task_requests = Arc::clone(&requests);
let task_responses = Arc::clone(&responses);
let task = tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
let active = Arc::clone(&task_active);
let max_active = Arc::clone(&task_max);
let requests = Arc::clone(&task_requests);
let responses = Arc::clone(&task_responses);
tokio::spawn(async move {
let mut request = Vec::new();
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let mut chunk = [0_u8; 2048];
let Ok(read) = socket.read(&mut chunk).await else {
return;
};
if read == 0 {
return;
}
request.extend_from_slice(&chunk[..read]);
}
requests.lock().await.push(String::from_utf8_lossy(&request).to_string());
let current = active.fetch_add(1, Ordering::SeqCst) + 1;
max_active.fetch_max(current, Ordering::SeqCst);
let response = responses.lock().await.pop_front().unwrap_or(TestOriginResponse {
status: 500,
headers: Vec::new(),
body: Vec::new(),
});
active.fetch_sub(1, Ordering::SeqCst);
let reason = if response.status == 200 { "OK" } else { "Status" };
let mut head =
format!("HTTP/1.1 {} {reason}\r\nContent-Length: {}\r\n", response.status, response.body.len());
for (name, value) in response.headers {
head.push_str(&format!("{name}: {value}\r\n"));
}
head.push_str("Connection: close\r\n\r\n");
let _ = socket.write_all(head.as_bytes()).await;
let _ = socket.write_all(&response.body).await;
});
}
});
TestSegmentServer { base_url: format!("http://{addr}"), max_active, requests, task }
}
fn temp_cache_files(root: &Path) -> Vec<PathBuf> {
fn visit(path: &Path, found: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(path) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
visit(&path, found);
} else if path.file_name().and_then(|name| name.to_str()).is_some_and(|name| name.contains(".tmp.")) {
found.push(path);
}
}
}
let mut found = Vec::new();
visit(root, &mut found);
found
}
async fn spawn_segment_server(delay_ms: u64) -> TestSegmentServer {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds");
let addr = listener.local_addr().expect("local addr");
@@ -1189,6 +1302,21 @@ mod tests {
}
}
async fn committed_segment(
context: &SegmentFetchContext,
proxy_seq: u64,
) -> (crate::api::model::SegmentCacheKey, Vec<u8>) {
let cache_key = context.session.read().await.segments.get(&proxy_seq).expect("segment").cache_key.clone();
let metadata = context
.segment_cache
.metadata(&cache_key)
.await
.expect("cache metadata reads")
.expect("cache object exists");
let bytes = tokio::fs::read(metadata.path).await.expect("cache object reads");
(cache_key, bytes)
}
#[tokio::test]
async fn demand_fetch_writes_cache_and_sets_ready_after_commit() {
let server = spawn_segment_server(0).await;
@@ -1207,6 +1335,133 @@ mod tests {
assert!(matches!(session.segments.get(&1).expect("segment").status, SegmentCacheStatus::Ready { .. }));
}
#[tokio::test]
async fn segment_declared_codings_decode_to_identity_before_cache_and_preserve_opaque_bytes() {
let identity_bytes = b"\x00\xffopaque-hls-ciphertext\x1f\x8bmedia".to_vec();
for encoding in [
TestContentEncoding::Gzip,
TestContentEncoding::RawDeflate,
TestContentEncoding::Brotli,
TestContentEncoding::Zstd,
] {
let encoded = encode_test_body(&identity_bytes, encoding).await;
let server =
spawn_sequence_response_server(vec![TestOriginResponse::encoded(encoding.header_value(), encoded)])
.await;
let temp_dir = tempfile::tempdir().expect("temp dir");
let policy = SegmentFetchPolicy {
retry_delays_ms: [0, 0, 0, 0, 0],
retry_jitter_max_ms: 0,
..SegmentFetchPolicy::default()
};
let (worker, context, segment_file) = fetch_context(&server, &temp_dir, &policy).await;
clear_scheduled_prefetch(&context, &policy).await;
let outcome = worker.demand_fetch_and_wait(context.clone(), &segment_file, 20).await;
assert_eq!(outcome, super::SegmentDemandFetchOutcome::Ready, "failed for {encoding:?}");
let (cache_key, cached) = committed_segment(&context, 1).await;
assert_eq!(cached, identity_bytes, "cache retained HTTP coding for {encoding:?}");
let mut range = context.segment_cache.open_range(&cache_key, 5).await.expect("cache range opens");
let mut ranged = Vec::new();
range.read_to_end(&mut ranged).await.expect("cache range reads");
assert_eq!(ranged, identity_bytes[5..], "range did not address Identity bytes for {encoding:?}");
let requests = server.requests.lock().await;
assert_eq!(requests.len(), 1, "unexpected retries for {encoding:?}");
assert!(
requests[0].to_ascii_lowercase().contains("accept-encoding: identity"),
"request did not enforce identity for {encoding:?}"
);
}
}
#[tokio::test]
async fn segment_decoder_failure_retries_then_commits_and_cleans_temp_file() {
let identity_bytes = b"identity-media-after-retry".to_vec();
let valid = encode_test_body(&identity_bytes, TestContentEncoding::Gzip).await;
let mut corrupt = valid.clone();
corrupt.truncate(corrupt.len() / 2);
let server = spawn_sequence_response_server(vec![
TestOriginResponse::encoded("gzip", corrupt),
TestOriginResponse::encoded("gzip", valid),
])
.await;
let temp_dir = tempfile::tempdir().expect("temp dir");
let policy = SegmentFetchPolicy {
retry_delays_ms: [0, 0, 0, 0, 0],
retry_jitter_max_ms: 0,
..SegmentFetchPolicy::default()
};
let (worker, context, segment_file) = fetch_context(&server, &temp_dir, &policy).await;
clear_scheduled_prefetch(&context, &policy).await;
let outcome = worker.demand_fetch_and_wait(context.clone(), &segment_file, 20).await;
assert_eq!(outcome, super::SegmentDemandFetchOutcome::Ready);
assert_eq!(committed_segment(&context, 1).await.1, identity_bytes);
let requests = server.requests.lock().await;
assert_eq!(requests.len(), 2);
assert!(requests.iter().all(|request| request.to_ascii_lowercase().contains("accept-encoding: identity")));
drop(requests);
assert!(!context.segment_cache.has_active_temp_files().await);
assert!(temp_cache_files(temp_dir.path()).is_empty());
}
#[tokio::test]
async fn segment_decoder_failure_exhausts_attempt_budget_without_cache_or_temp_file() {
let valid = encode_test_body(b"never committed", TestContentEncoding::Gzip).await;
let mut corrupt = valid;
corrupt.truncate(corrupt.len() / 2);
let server = spawn_sequence_response_server(
(0..5).map(|_| TestOriginResponse::encoded("gzip", corrupt.clone())).collect(),
)
.await;
let temp_dir = tempfile::tempdir().expect("temp dir");
let policy = SegmentFetchPolicy {
retry_delays_ms: [0, 0, 0, 0, 0],
retry_jitter_max_ms: 0,
..SegmentFetchPolicy::default()
};
let (worker, context, segment_file) = fetch_context(&server, &temp_dir, &policy).await;
clear_scheduled_prefetch(&context, &policy).await;
let outcome = worker.demand_fetch_and_wait(context.clone(), &segment_file, 20).await;
assert_eq!(outcome, super::SegmentDemandFetchOutcome::TimedOut);
assert_eq!(server.requests.lock().await.len(), 5);
let cache_key = context.session.read().await.segments.get(&1).expect("segment").cache_key.clone();
assert!(context.segment_cache.metadata(&cache_key).await.expect("metadata reads").is_none());
assert!(!context.segment_cache.has_active_temp_files().await);
assert!(temp_cache_files(temp_dir.path()).is_empty());
}
#[tokio::test]
async fn segment_decoded_object_limit_is_authoritative_and_non_retryable() {
let identity_bytes = vec![b'x'; 512];
let encoded = encode_test_body(&identity_bytes, TestContentEncoding::Gzip).await;
assert!(encoded.len() < 64, "fixture must be smaller on the wire than the decoded limit");
let server = spawn_sequence_response_server(vec![TestOriginResponse::encoded("gzip", encoded)]).await;
let temp_dir = tempfile::tempdir().expect("temp dir");
let policy = SegmentFetchPolicy {
retry_delays_ms: [0, 0, 0, 0, 0],
retry_jitter_max_ms: 0,
..SegmentFetchPolicy::default()
};
let (worker, context, segment_file) = fetch_context(&server, &temp_dir, &policy).await;
context.segment_cache.update_cache_limits(64, 64);
clear_scheduled_prefetch(&context, &policy).await;
let outcome = worker.demand_fetch_and_wait(context.clone(), &segment_file, 20).await;
assert_eq!(outcome, super::SegmentDemandFetchOutcome::Unavailable);
assert_eq!(server.requests.lock().await.len(), 1);
let cache_key = context.session.read().await.segments.get(&1).expect("segment").cache_key.clone();
assert!(context.segment_cache.metadata(&cache_key).await.expect("metadata reads").is_none());
assert!(!context.segment_cache.has_active_temp_files().await);
assert!(temp_cache_files(temp_dir.path()).is_empty());
}
#[tokio::test]
async fn demand_fetch_is_blocked_for_gc_marked_session() {
let server = spawn_segment_server(0).await;
@@ -5,11 +5,11 @@ use super::{
CachedSegmentMetadata, HlsAccessLeaseId, HlsCacheObjectKey, HlsSegmentCache, ProxySessionId, StagedCacheObject,
};
use crate::model::{HlsCorruptSegmentWatchdogConfig, HlsSegmentRepairConfig};
use shared::model::{HlsSegmentRepairExecutionPlan, HlsSegmentRepairMode};
use arc_swap::ArcSwap;
use log::{debug, warn};
use serde_json::Value;
use sha2::{Digest, Sha256};
use shared::model::{HlsSegmentRepairExecutionPlan, HlsSegmentRepairMode};
use std::{
collections::{HashMap, HashSet, VecDeque},
fmt, io,
@@ -542,14 +542,14 @@ impl HlsSegmentRepairManager {
segment_cache: &HlsSegmentCache,
key: &K,
reader: R,
deadline: Duration,
deadline: tokio::time::Instant,
context: HlsSegmentRepairObjectContext,
) -> io::Result<CachedSegmentMetadata>
where
K: HlsCacheObjectKey,
R: AsyncRead + Unpin,
{
let raw = segment_cache.stage_temp_with_timeout(key, reader, deadline).await?;
let raw = segment_cache.stage_temp_with_deadline(key, reader, deadline).await?;
let runtime = self.runtime.load_full();
if !runtime.postprocessing_enabled() {
return segment_cache.commit_staged(key, raw).await;
@@ -940,7 +940,8 @@ impl HlsSegmentRepairManager {
return Ok(None);
}
};
let validation = validate_repair(&runtime.config, codec, &raw_scan, &fixed_scan, executed_level, &stream_selection);
let validation =
validate_repair(&runtime.config, codec, &raw_scan, &fixed_scan, executed_level, &stream_selection);
if let Err(reason) = validation {
debug_repair_event(context, executed_level, "validation failed", Some(&reason));
let _ = fs::remove_file(&fixed_path).await;
@@ -1176,10 +1177,7 @@ struct RepairRemuxStreamSelection {
#[cfg(test)]
impl RepairRemuxStreamSelection {
fn preserve_all(probe: &SegmentProbe) -> Self {
Self {
mapped_streams: probe.streams.iter().map(|stream| stream.index).collect(),
dropped_streams: Vec::new(),
}
Self { mapped_streams: probe.streams.iter().map(|stream| stream.index).collect(), dropped_streams: Vec::new() }
}
}
@@ -1774,10 +1772,8 @@ fn parse_probe(json: &str, warnings: WarningCounters) -> Result<SegmentProbe, St
.get("extradata_size")
.and_then(Value::as_u64)
.or_else(|| stream.get("extradata_size").and_then(Value::as_str).and_then(|value| value.parse().ok()));
let stream_index = stream
.get("index")
.and_then(parse_u32_value)
.map_or(probe.streams.len(), |value| value as usize);
let stream_index =
stream.get("index").and_then(parse_u32_value).map_or(probe.streams.len(), |value| value as usize);
let probe_stream = SegmentProbeStream {
index: stream_index,
stream_type: match codec_type {
@@ -1839,18 +1835,12 @@ fn select_repair_remux_streams(probe: &SegmentProbe) -> Result<RepairRemuxStream
SegmentProbeStreamType::Audio if valid_audio_stream(stream) => {
mapped_streams.push(stream.index);
}
SegmentProbeStreamType::Video => dropped_streams.push(RepairRemuxDroppedStream {
index: stream.index,
reason: "invalid-video-parameters",
}),
SegmentProbeStreamType::Audio => dropped_streams.push(RepairRemuxDroppedStream {
index: stream.index,
reason: "invalid-audio-parameters",
}),
SegmentProbeStreamType::Other => dropped_streams.push(RepairRemuxDroppedStream {
index: stream.index,
reason: "unsupported-stream-type",
}),
SegmentProbeStreamType::Video => dropped_streams
.push(RepairRemuxDroppedStream { index: stream.index, reason: "invalid-video-parameters" }),
SegmentProbeStreamType::Audio => dropped_streams
.push(RepairRemuxDroppedStream { index: stream.index, reason: "invalid-audio-parameters" }),
SegmentProbeStreamType::Other => dropped_streams
.push(RepairRemuxDroppedStream { index: stream.index, reason: "unsupported-stream-type" }),
}
}
if !has_video {
@@ -2001,10 +1991,10 @@ impl fmt::Display for RepairStatus {
#[cfg(test)]
mod tests {
use super::{
detect_video_codec, parse_ffmpeg_warnings, parse_probe, repair_object_metadata_key, select_repair_remux_streams,
sha256_file, validate_repair, HlsRepairObjectMetadata, HlsRepairRenderedObjectId, HlsSegmentRepairManager,
HlsSegmentRepairObjectContext, HlsSegmentRepairSource, RepairIdentity, RepairRemuxStreamSelection,
RepairStatus, RepairVideoCodec, WarningCounters, REPAIR_METADATA_MAX_ENTRIES,
detect_video_codec, parse_ffmpeg_warnings, parse_probe, repair_object_metadata_key,
select_repair_remux_streams, sha256_file, validate_repair, HlsRepairObjectMetadata, HlsRepairRenderedObjectId,
HlsSegmentRepairManager, HlsSegmentRepairObjectContext, HlsSegmentRepairSource, RepairIdentity,
RepairRemuxStreamSelection, RepairStatus, RepairVideoCodec, WarningCounters, REPAIR_METADATA_MAX_ENTRIES,
};
use crate::{
api::model::{
@@ -2013,8 +2003,8 @@ mod tests {
},
model::HlsSegmentRepairConfig,
};
use std::sync::Arc;
use shared::model::HlsSegmentRepairMode;
use std::sync::Arc;
fn repair_config(mode: HlsSegmentRepairMode, apply_to_first_segments: u8) -> HlsSegmentRepairConfig {
HlsSegmentRepairConfig {
@@ -2550,10 +2540,7 @@ mod tests {
assert_eq!(selected_repair_mode(&manager, &first).await, Some(HlsSegmentRepairMode::Low));
assert_eq!(selected_repair_mode(&manager, &same_rendered_object_other_fetch_uri).await, None);
assert_eq!(
selected_repair_mode(&manager, &second_rendered_object).await,
Some(HlsSegmentRepairMode::Low)
);
assert_eq!(selected_repair_mode(&manager, &second_rendered_object).await, Some(HlsSegmentRepairMode::Low));
}
#[tokio::test]
@@ -8,9 +8,9 @@ use super::{
CachedSegmentMetadata, HlsCacheObjectKey, HlsSegmentCache, StagedCacheObject,
};
use crate::model::HlsCorruptSegmentWatchdogConfig;
use shared::model::HlsCorruptSegmentWatchdogMode;
use log::debug;
use serde_json::Value;
use shared::model::HlsCorruptSegmentWatchdogMode;
use std::{
collections::{HashMap, VecDeque},
ffi::OsString,
@@ -261,7 +261,12 @@ impl HlsCorruptSegmentWatchdogManager {
Some(format!("unsupported_container:{extension}")),
)
.await;
debug_watchdog_event(context, config.mode, "detected corrupt", Some("action=raw_commit reason=unsupported_container"));
debug_watchdog_event(
context,
config.mode,
"detected corrupt",
Some("action=raw_commit reason=unsupported_container"),
);
return segment_cache.commit_staged(key, raw).await;
}
let fixed_path = watchdog_output_path(&raw.path);
+5 -5
View File
@@ -1,10 +1,10 @@
use super::{
build_proxy_session_id, classify_account_binding_protection, HlsAccountBindingProtection, HlsAccountOverlapTiming,
HlsEffectiveOriginAcquirePolicy, HlsEffectiveOriginAcquirePolicyState, HlsFreshManifestRequiredReason,
HlsOriginAccountBinding, HlsOriginAccountIoLease, HlsOriginAccountRebindState, HlsOriginSource, HlsSessionKey,
HlsBoundAccountAcquireErrorKind, MapCacheStatus, MapEntry, OriginMapKey, OriginRefreshState, OriginSegmentKey,
ProxyMapId, ProxySessionId, RenderPolicy, RenderedManifest, SegmentCacheStatus, SegmentEntry,
SegmentFetchPriority, SegmentPrefetchQueue, TransientPassthroughState,
HlsBoundAccountAcquireErrorKind, HlsEffectiveOriginAcquirePolicy, HlsEffectiveOriginAcquirePolicyState,
HlsFreshManifestRequiredReason, HlsOriginAccountBinding, HlsOriginAccountIoLease, HlsOriginAccountRebindState,
HlsOriginSource, HlsSessionKey, MapCacheStatus, MapEntry, OriginMapKey, OriginRefreshState, OriginSegmentKey,
ProxyMapId, ProxySessionId, RenderPolicy, RenderedManifest, SegmentCacheStatus, SegmentEntry, SegmentFetchPriority,
SegmentPrefetchQueue, TransientPassthroughState,
};
use axum::http::{HeaderMap, StatusCode};
use std::{
+9 -17
View File
@@ -271,10 +271,10 @@ impl TimelineDraft {
self.target_duration = manifest.target_duration;
self.independent_segments = manifest.independent_segments;
self.apply_forward_jump(manifest)?;
let epoch_transition_discontinuity = self.apply_origin_epoch_transition_for_manifest(manifest, origin_epoch_handoff)?;
let epoch_transition_discontinuity =
self.apply_origin_epoch_transition_for_manifest(manifest, origin_epoch_handoff)?;
let mut mark_handoff_discontinuity =
handoff_discontinuity_sequence.is_some() || epoch_transition_discontinuity;
let mut mark_handoff_discontinuity = handoff_discontinuity_sequence.is_some() || epoch_transition_discontinuity;
let mut manifest_head_proxy_seq = None;
let mut manifest_tail_proxy_seq = None;
for parsed in &manifest.segments {
@@ -295,19 +295,15 @@ impl TimelineDraft {
origin_epoch_handoff: bool,
) -> Result<bool, TimelineMapError> {
if origin_epoch_handoff && self.should_start_new_origin_epoch_for_handoff(manifest) {
let next_origin_seq = manifest
.segments
.first()
.map_or(manifest.origin_manifest_sequence, |segment| segment.origin_seq);
let next_origin_seq =
manifest.segments.first().map_or(manifest.origin_manifest_sequence, |segment| segment.origin_seq);
self.start_new_origin_epoch(next_origin_seq, OriginEpochTransitionReason::Handoff)?;
return Ok(true);
}
if self.should_start_new_origin_epoch_for_rollover(manifest) {
let next_origin_seq = manifest
.segments
.first()
.map_or(manifest.origin_manifest_sequence, |segment| segment.origin_seq);
let next_origin_seq =
manifest.segments.first().map_or(manifest.origin_manifest_sequence, |segment| segment.origin_seq);
self.start_new_origin_epoch(next_origin_seq, OriginEpochTransitionReason::Rollover)?;
return Ok(true);
}
@@ -484,9 +480,7 @@ fn proxy_extension_from_url(url: &str, allowed_extensions: &[&str]) -> Option<St
mod tests {
use super::{SegmentCacheStatus, TimelineMapError};
use crate::{
api::model::{
HlsSession, HlsSessionKey, MapCacheStatus, OriginSegmentKey, ProxyMapId, SegmentFetchPriority,
},
api::model::{HlsSession, HlsSessionKey, MapCacheStatus, OriginSegmentKey, ProxyMapId, SegmentFetchPriority},
processing::parser::hls::origin_manifest::{parse_origin_media_manifest, OriginManifestParseOutcome},
};
@@ -654,9 +648,7 @@ mod tests {
#[test]
fn host_handoff_starts_new_epoch_when_range_overlaps_highwater() {
let mut session = session();
let first = normal_manifest(
"#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:100\n#EXTINF:4.0,\n100.ts\n#EXTINF:4.0,\n101.ts\n",
);
let first = normal_manifest("#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:100\n#EXTINF:4.0,\n100.ts\n#EXTINF:4.0,\n101.ts\n");
let second = normal_manifest(
"#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:99\n#EXTINF:4.0,\n99.ts\n#EXTINF:4.0,\n100.ts\n#EXTINF:4.0,\n101.ts\n",
);
+2 -6
View File
@@ -39,6 +39,7 @@ pub enum TransientResourceKind {
Segment,
Key,
Map,
Part,
Other,
}
@@ -230,12 +231,7 @@ impl TransientPassthroughState {
self.last_manifest_valid_until_ms = None;
}
pub fn replace_manifest_with_validity(
&mut self,
body: String,
rendered_at_ms: u64,
playlist_duration_ms: u64,
) {
pub fn replace_manifest_with_validity(&mut self, body: String, rendered_at_ms: u64, playlist_duration_ms: u64) {
self.last_manifest_body = Some(body);
self.last_manifest_rendered_at_ms = Some(rendered_at_ms);
self.last_manifest_playlist_duration_ms = Some(playlist_duration_ms);
File diff suppressed because it is too large Load Diff
@@ -4250,6 +4250,7 @@ mod tests {
input_name: Arc::from("input"),
channel_no: 0,
source_ordinal: 0,
input_stream_id: Arc::from("42"),
};
InputWorker::apply_pending_generic_probe_base(&batch, XtreamCluster::Video, 42, &mut item);
+8 -6
View File
@@ -23,7 +23,10 @@ mod streams;
mod update_guard;
mod xtream;
pub(crate) use self::streams::*;
#[cfg(test)]
pub(in crate::api) use self::hls_provisioning::{
build_hls_custom_video_manifest_body, hls_panel_provisioning_manifest_path,
};
pub use self::{
active_provider_manager::*, app_state::*, connection_manager::*, event_manager::*, hls_cache::*,
hls_provisioning::HlsProvisioningState, metadata_update_manager::*, playlist_mem_cache::*, provider_dns_manager::*,
@@ -37,8 +40,7 @@ pub(in crate::api) use self::{
hls_custom_video_manifest_path, hls_custom_video_manifest_response_with_virtual_id,
hls_provisioning_discontinuity_sequence, hls_virtual_entry_redirect_response,
parse_hls_panel_provisioning_segment_route_name, start_hls_panel_provisioning_once,
try_hls_panel_provisioning_manifest_response, HlsPanelProvisioningRedirectPaths,
HlsProvisioningStatus,
try_hls_panel_provisioning_manifest_response, HlsPanelProvisioningRedirectPaths, HlsProvisioningStatus,
},
model_utils::*,
provider_config::*,
@@ -48,9 +50,9 @@ pub(in crate::api) use self::{
stream_error::*,
xtream::*,
};
#[cfg(test)]
pub(in crate::api) use self::hls_provisioning::{
build_hls_custom_video_manifest_body, hls_panel_provisioning_manifest_path,
pub(crate) use self::{
hls_cache::{log_hls_origin_content_coding, HlsOriginContentCodingObjectKind, HlsOriginContentCodingSource},
streams::*,
};
mod batch_result_collector;
pub use self::batch_result_collector::*;
+320 -1
View File
@@ -1,4 +1,11 @@
use reqwest::{header::HeaderMap, StatusCode};
use crate::{
api::model::{ProviderContentRepresentationMode, ProviderStreamHeader},
utils::content_coding::parse_content_encoding_tokens,
};
use reqwest::{
header::{HeaderMap, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, TRANSFER_ENCODING},
StatusCode,
};
use shared::utils::filter_response_header;
use std::{collections::HashSet, str::FromStr};
@@ -10,6 +17,96 @@ pub fn get_response_headers(headers: &HeaderMap) -> Vec<(String, String)> {
.collect()
}
/// Failures raised while selecting representation-sensitive provider response headers.
#[allow(clippy::enum_variant_names)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
pub(crate) enum ProviderResponseHeaderError {
#[error("invalid Content-Encoding header")]
InvalidContentEncoding,
#[error("invalid Content-Length header")]
InvalidContentLength,
#[error("invalid Content-Range header")]
InvalidContentRange,
}
/// Selects representation-consistent provider headers without widening the global response allowlist.
pub(crate) fn provider_response_headers(
headers: &HeaderMap,
mode: ProviderContentRepresentationMode,
) -> Result<ProviderStreamHeader, ProviderResponseHeaderError> {
let mut selected = headers
.iter()
.filter(|(key, _)| {
let name = key.as_str();
name != CONTENT_ENCODING.as_str()
&& name != CONTENT_LENGTH.as_str()
&& name != CONTENT_RANGE.as_str()
&& name != TRANSFER_ENCODING.as_str()
})
.filter(|(key, _)| filter_response_header(key.as_str()))
.filter_map(|(key, value)| value.to_str().ok().map(|value| (key.to_string(), value.to_string())))
.collect::<Vec<_>>();
if matches!(mode, ProviderContentRepresentationMode::PreserveOrigin) {
let content_encoding =
parse_content_encoding_tokens(headers).map_err(|_| ProviderResponseHeaderError::InvalidContentEncoding)?;
if !content_encoding.is_empty() {
selected.push((CONTENT_ENCODING.as_str().to_owned(), content_encoding.join(", ")));
}
}
if let Some(content_length) = validated_content_length(headers)? {
selected.push((CONTENT_LENGTH.as_str().to_owned(), content_length));
}
if let Some(content_range) = validated_content_range(headers)? {
selected.push((CONTENT_RANGE.as_str().to_owned(), content_range));
}
Ok(selected)
}
fn validated_content_length(headers: &HeaderMap) -> Result<Option<String>, ProviderResponseHeaderError> {
let mut content_length = None;
for value in headers.get_all(CONTENT_LENGTH) {
let value = value.to_str().map_err(|_| ProviderResponseHeaderError::InvalidContentLength)?;
for value in value.split(',') {
let value = value.trim_matches(|character| matches!(character, ' ' | '\t'));
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(ProviderResponseHeaderError::InvalidContentLength);
}
let value = value.parse::<u64>().map_err(|_| ProviderResponseHeaderError::InvalidContentLength)?;
match content_length {
Some(expected) if value != expected => {
return Err(ProviderResponseHeaderError::InvalidContentLength);
}
Some(_) => {}
None => content_length = Some(value),
}
}
}
Ok(content_length.map(|value| value.to_string()))
}
fn validated_content_range(headers: &HeaderMap) -> Result<Option<String>, ProviderResponseHeaderError> {
let mut values = headers.get_all(CONTENT_RANGE).iter();
let Some(value) = values.next() else {
return Ok(None);
};
if values.next().is_some() {
return Err(ProviderResponseHeaderError::InvalidContentRange);
}
let value = value.to_str().map_err(|_| ProviderResponseHeaderError::InvalidContentRange)?;
let value = value.trim_matches(|character| matches!(character, ' ' | '\t'));
if value.is_empty() || value.contains(',') {
return Err(ProviderResponseHeaderError::InvalidContentRange);
}
Ok(Some(value.to_owned()))
}
pub fn get_stream_response_with_headers(
custom: Option<(Vec<(String, String)>, StatusCode)>,
) -> (axum::http::StatusCode, axum::http::HeaderMap) {
@@ -20,6 +117,9 @@ pub fn get_stream_response_with_headers(
if let Some((custom_headers, status_code)) = custom {
status = status_code;
for (key, value) in custom_headers {
if key.eq_ignore_ascii_case(TRANSFER_ENCODING.as_str()) {
continue;
}
if let (Ok(name), Ok(val)) =
(axum::http::HeaderName::from_str(&key), axum::http::HeaderValue::from_str(&value))
{
@@ -47,3 +147,222 @@ pub fn get_stream_response_with_headers(
(status, headers)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::content_coding::normalize_headers_after_content_decoding;
use reqwest::header::{
ACCEPT_RANGES, AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, ETAG, PROXY_AUTHENTICATE, SET_COOKIE,
WWW_AUTHENTICATE,
};
fn selected_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers.iter().find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value.as_str()))
}
#[test]
fn provider_preserve_origin_keeps_representation_headers_and_drops_unsafe_headers() {
let mut headers = HeaderMap::new();
headers.append(CONTENT_ENCODING, "gzip".parse().unwrap());
headers.append(CONTENT_ENCODING, "br".parse().unwrap());
headers.insert(CONTENT_LENGTH, "321".parse().unwrap());
headers.insert(CONTENT_RANGE, "bytes 10-330/1000".parse().unwrap());
headers.insert(TRANSFER_ENCODING, "chunked".parse().unwrap());
headers.insert(SET_COOKIE, "provider_session=secret".parse().unwrap());
headers.insert(AUTHORIZATION, "Bearer secret".parse().unwrap());
headers.insert("x-provider-secret", "secret".parse().unwrap());
let selected = provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin)
.expect("valid provider headers");
assert_eq!(selected_value(&selected, CONTENT_ENCODING.as_str()), Some("gzip, br"));
assert_eq!(selected_value(&selected, CONTENT_LENGTH.as_str()), Some("321"));
assert_eq!(selected_value(&selected, CONTENT_RANGE.as_str()), Some("bytes 10-330/1000"));
for rejected in [TRANSFER_ENCODING.as_str(), SET_COOKIE.as_str(), AUTHORIZATION.as_str(), "x-provider-secret"] {
assert_eq!(selected_value(&selected, rejected), None, "unexpected header {rejected}");
}
}
#[test]
fn provider_preserve_origin_keeps_unknown_valid_content_coding() {
let mut headers = HeaderMap::new();
headers.append(CONTENT_ENCODING, "gzip, X-Provider-Coding".parse().unwrap());
headers.append(CONTENT_ENCODING, "compress".parse().unwrap());
let selected = provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin)
.expect("valid provider headers");
assert_eq!(selected_value(&selected, CONTENT_ENCODING.as_str()), Some("gzip, X-Provider-Coding, compress"));
}
#[test]
fn provider_preserve_origin_rejects_malformed_or_non_text_content_encoding() {
for value in [
"gzip,,br".parse().unwrap(),
reqwest::header::HeaderValue::from_bytes(&[0xff]).expect("opaque header value"),
] {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_ENCODING, value);
assert_eq!(
provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin),
Err(ProviderResponseHeaderError::InvalidContentEncoding)
);
}
}
#[test]
fn provider_preserve_origin_canonicalizes_identical_content_lengths() {
let mut headers = HeaderMap::new();
headers.append(CONTENT_LENGTH, "00321".parse().unwrap());
headers.append(CONTENT_LENGTH, "321, 321".parse().unwrap());
let selected = provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin)
.expect("matching content lengths");
assert_eq!(selected_value(&selected, CONTENT_LENGTH.as_str()), Some("321"));
}
#[test]
fn provider_preserve_origin_rejects_invalid_or_conflicting_content_lengths() {
for values in [&["321", "322"][..], &["32x"][..], &[""][..]] {
let mut headers = HeaderMap::new();
for value in values {
headers.append(CONTENT_LENGTH, (*value).parse().unwrap());
}
assert_eq!(
provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin),
Err(ProviderResponseHeaderError::InvalidContentLength)
);
}
let mut headers = HeaderMap::new();
headers.insert(CONTENT_LENGTH, reqwest::header::HeaderValue::from_bytes(&[0xff]).expect("opaque header value"));
assert_eq!(
provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin),
Err(ProviderResponseHeaderError::InvalidContentLength)
);
}
#[test]
fn provider_preserve_origin_rejects_ambiguous_or_non_text_content_range() {
let mut headers = HeaderMap::new();
headers.append(CONTENT_RANGE, "bytes 0-9/20".parse().unwrap());
headers.append(CONTENT_RANGE, "bytes 10-19/20".parse().unwrap());
assert_eq!(
provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin),
Err(ProviderResponseHeaderError::InvalidContentRange)
);
let mut headers = HeaderMap::new();
headers.insert(CONTENT_RANGE, reqwest::header::HeaderValue::from_bytes(&[0xff]).expect("opaque header value"));
assert_eq!(
provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin),
Err(ProviderResponseHeaderError::InvalidContentRange)
);
for value in ["", "bytes 0-9/20, bytes 10-19/20"] {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_RANGE, value.parse().unwrap());
assert_eq!(
provider_response_headers(&headers, ProviderContentRepresentationMode::PreserveOrigin),
Err(ProviderResponseHeaderError::InvalidContentRange)
);
}
}
#[test]
fn provider_identity_keeps_untransformed_content_length_and_content_range() {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_LENGTH, "10".parse().unwrap());
headers.insert(CONTENT_RANGE, "bytes 10-19/100".parse().unwrap());
headers.insert(TRANSFER_ENCODING, "chunked".parse().unwrap());
let selected = provider_response_headers(&headers, ProviderContentRepresentationMode::Identity)
.expect("valid untransformed identity headers");
assert_eq!(selected_value(&selected, CONTENT_LENGTH.as_str()), Some("10"));
assert_eq!(selected_value(&selected, CONTENT_RANGE.as_str()), Some("bytes 10-19/100"));
assert_eq!(selected_value(&selected, CONTENT_ENCODING.as_str()), None);
assert_eq!(selected_value(&selected, TRANSFER_ENCODING.as_str()), None);
}
#[test]
fn provider_identity_rejects_invalid_untransformed_length_and_range() {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_LENGTH, "32x".parse().unwrap());
assert_eq!(
provider_response_headers(&headers, ProviderContentRepresentationMode::Identity),
Err(ProviderResponseHeaderError::InvalidContentLength)
);
let mut headers = HeaderMap::new();
headers.append(CONTENT_RANGE, "bytes 0-9/20".parse().unwrap());
headers.append(CONTENT_RANGE, "bytes 10-19/20".parse().unwrap());
assert_eq!(
provider_response_headers(&headers, ProviderContentRepresentationMode::Identity),
Err(ProviderResponseHeaderError::InvalidContentRange)
);
}
#[test]
fn provider_identity_uses_only_safe_normalized_headers() {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_ENCODING, "zstd".parse().unwrap());
headers.insert(CONTENT_LENGTH, "99".parse().unwrap());
headers.insert(CONTENT_RANGE, "bytes 0-98/99".parse().unwrap());
headers.insert(ACCEPT_RANGES, "bytes".parse().unwrap());
headers.insert(ETAG, "\"encoded\"".parse().unwrap());
headers.insert(TRANSFER_ENCODING, "chunked".parse().unwrap());
headers.insert(CONTENT_TYPE, "video/mp2t".parse().unwrap());
headers.insert(CACHE_CONTROL, "public, max-age=30".parse().unwrap());
headers.insert(SET_COOKIE, "provider_session=secret".parse().unwrap());
headers.insert(PROXY_AUTHENTICATE, "Basic realm=provider".parse().unwrap());
headers.insert(WWW_AUTHENTICATE, "Basic realm=provider".parse().unwrap());
headers.insert("x-provider-secret", "secret".parse().unwrap());
normalize_headers_after_content_decoding(&mut headers);
let selected =
provider_response_headers(&headers, ProviderContentRepresentationMode::for_playback_extension(".m3u8"))
.expect("valid normalized provider headers");
assert_eq!(selected_value(&selected, CONTENT_TYPE.as_str()), Some("video/mp2t"));
assert_eq!(selected_value(&selected, CACHE_CONTROL.as_str()), Some("public, max-age=30"));
for rejected in [
CONTENT_ENCODING.as_str(),
CONTENT_LENGTH.as_str(),
CONTENT_RANGE.as_str(),
ACCEPT_RANGES.as_str(),
ETAG.as_str(),
TRANSFER_ENCODING.as_str(),
SET_COOKIE.as_str(),
PROXY_AUTHENTICATE.as_str(),
WWW_AUTHENTICATE.as_str(),
"x-provider-secret",
] {
assert_eq!(selected_value(&selected, rejected), None, "unexpected header {rejected}");
}
}
#[test]
fn rebuilt_stream_responses_never_forward_transfer_encoding() {
let mut origin_headers = HeaderMap::new();
origin_headers.insert(TRANSFER_ENCODING, "chunked".parse().unwrap());
origin_headers.insert(CONTENT_TYPE, "video/mp2t".parse().unwrap());
let selected = get_response_headers(&origin_headers);
assert_eq!(selected_value(&selected, TRANSFER_ENCODING.as_str()), None);
let (_, rebuilt) = get_stream_response_with_headers(Some((
vec![
(TRANSFER_ENCODING.as_str().to_owned(), "chunked".to_owned()),
(CONTENT_TYPE.as_str().to_owned(), "video/mp2t".to_owned()),
],
StatusCode::OK,
)));
assert!(!rebuilt.contains_key(TRANSFER_ENCODING));
assert_eq!(rebuilt[CONTENT_TYPE], "video/mp2t");
}
}
+49 -5
View File
@@ -5,7 +5,10 @@ use crate::{
use axum::http::StatusCode;
use bytes::Bytes;
use futures::stream::BoxStream;
use shared::model::{PlaylistItemType, StreamChannel};
use shared::{
defaults::HLS_EXT,
model::{PlaylistItemType, StreamChannel},
};
use std::{collections::HashMap, sync::Arc};
use tokio_util::sync::CancellationToken;
use url::Url;
@@ -18,6 +21,24 @@ pub type ProviderStreamResponse = (Option<BoxedProviderStream>, ProviderStreamIn
pub type ProviderStreamFactoryResponse = (BoxedProviderStream, ProviderStreamInfo);
/// Controls whether a provider stream preserves its origin representation or normalizes it to identity bytes.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum ProviderContentRepresentationMode {
#[default]
PreserveOrigin,
Identity,
}
impl ProviderContentRepresentationMode {
pub(crate) fn for_playback_extension(extension: &str) -> Self {
if extension.eq_ignore_ascii_case(HLS_EXT) {
Self::Identity
} else {
Self::PreserveOrigin
}
}
}
pub(crate) fn uses_direct_body_idle_timeout(stream_channel: &StreamChannel) -> bool {
!stream_channel.shared
&& matches!(
@@ -39,10 +60,7 @@ pub enum ProviderStreamCustomReason {
}
pub enum ProviderStreamState {
Custom {
response: ProviderStreamResponse,
reason: ProviderStreamCustomReason,
},
Custom { response: ProviderStreamResponse, reason: ProviderStreamCustomReason },
Available(Option<ProviderName>, StreamUrl),
GracePeriod(Option<ProviderName>, StreamUrl),
}
@@ -58,6 +76,7 @@ pub struct StreamDetails {
pub disable_provider_grace: bool,
pub reconnect_flag: Option<CancellationToken>,
pub provider_handle: Option<ProviderHandle>,
pub(crate) content_representation: ProviderContentRepresentationMode,
/// Set when the stream was admitted via a user-grace strategy. Carried through to
/// `stream_grace_period` so remaining strategies can be evaluated if the grace fails.
pub(crate) grace_resolution_context: Option<crate::api::api_utils::GraceResolutionContext>,
@@ -79,6 +98,7 @@ impl Clone for StreamDetails {
disable_provider_grace: self.disable_provider_grace,
reconnect_flag: self.reconnect_flag.clone(),
provider_handle: self.provider_handle.clone(),
content_representation: self.content_representation,
grace_resolution_context: self.grace_resolution_context.clone(),
}
}
@@ -97,6 +117,7 @@ impl StreamDetails {
disable_provider_grace: false,
reconnect_flag: None,
provider_handle: None,
content_representation: ProviderContentRepresentationMode::PreserveOrigin,
grace_resolution_context: None,
}
}
@@ -122,3 +143,26 @@ pub struct StreamingStrategy {
pub provider_stream_state: ProviderStreamState,
pub input_headers: Option<HashMap<String, String>>,
}
#[cfg(test)]
mod tests {
use super::ProviderContentRepresentationMode;
#[test]
fn provider_representation_mode_uses_hls_extension_not_playlist_item_type() {
assert_eq!(
ProviderContentRepresentationMode::for_playback_extension(".m3u8"),
ProviderContentRepresentationMode::Identity
);
assert_eq!(
ProviderContentRepresentationMode::for_playback_extension(".M3U8"),
ProviderContentRepresentationMode::Identity
);
for extension in [".ts", ".mp4", ".mkv", ""] {
assert_eq!(
ProviderContentRepresentationMode::for_playback_extension(extension),
ProviderContentRepresentationMode::PreserveOrigin
);
}
}
}
+3
View File
@@ -5,6 +5,7 @@ use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
pub enum StreamError {
Reqwest { message: String, class: &'static str, status: Option<u16> },
StdIo(String),
ContentDecoding(String),
// ReceiverClosed,
ReceiverError(BroadcastStreamRecvError),
LockError(String),
@@ -81,6 +82,7 @@ impl StreamError {
match self {
Self::Reqwest { class, .. } => class,
Self::StdIo(_) => "io",
Self::ContentDecoding(_) => "content_decoding",
Self::ReceiverError(_) => "receiver",
Self::LockError(_) => "lock",
Self::Stream(_) => "stream",
@@ -105,6 +107,7 @@ impl std::fmt::Display for StreamError {
match self {
StreamError::Reqwest { message, .. } => write!(f, "Reqwest error: {message}"),
StreamError::StdIo(e) => write!(f, "IO error: {e}"),
StreamError::ContentDecoding(e) => write!(f, "Content decoding error: {e}"),
// StreamError::ReceiverClosed => write!(f, "Receiver closed"),
StreamError::ReceiverError(e) => write!(f, "Receiver error {e}"),
StreamError::Stream(e) => write!(f, "Stream: {e}"),
@@ -2,11 +2,11 @@ use crate::{
api::{
api_utils::get_stream_options,
model::{
AppState, BoxedProviderStream, CleanupEvent, ConnectionManager, CustomVideoStreamType, EventManager,
MeteringStream, PendingProviderWakeSource, ProviderHandle, ProviderStreamFactoryOptions, StreamDetails,
StreamError, StreamMeterHandle, TimedClientStream, TransportStreamBuffer,
connection_manager::{PROVIDER_END_CLOSED, PROVIDER_END_ERROR, PROVIDER_END_NOT_SET},
create_provider_stream, uses_direct_body_idle_timeout,
create_provider_stream, uses_direct_body_idle_timeout, AppState, BoxedProviderStream, CleanupEvent,
ConnectionManager, CustomVideoStreamType, EventManager, MeteringStream, PendingProviderWakeSource,
ProviderHandle, ProviderStreamFactoryOptions, StreamDetails, StreamError, StreamMeterHandle,
TimedClientStream, TransportStreamBuffer,
},
panel_api::{can_provision_on_exhausted, find_input_by_provider_name, run_panel_api_provisioning_probe},
},
@@ -14,22 +14,20 @@ use crate::{
model::{ConfigInput, ProxyUserCredentials},
utils::debug_if_enabled,
};
use axum::http::{HeaderMap, header::USER_AGENT};
use axum::http::{header::USER_AGENT, HeaderMap};
use bytes::Bytes;
use futures::{Future, Stream, StreamExt, task::AtomicWaker};
use futures::{task::AtomicWaker, Future, Stream, StreamExt};
use log::{error, info, warn};
use shared::model::FailureStage;
use shared::utils::Internable;
use shared::{
model::{StreamChannel, UserConnectionPermission, VirtualId},
utils::sanitize_sensitive_info,
model::{FailureStage, StreamChannel, UserConnectionPermission, VirtualId},
utils::{sanitize_sensitive_info, Internable},
};
use std::{
net::SocketAddr,
pin::Pin,
sync::{
Arc,
atomic::{AtomicU8, Ordering},
Arc,
},
task::{Context, Poll},
};
@@ -170,9 +168,7 @@ impl DirectBodyIdleTimeout {
Self { enabled: false, deadline: None, sleep: None, last_socket_activity_touch: None }
}
const fn enabled() -> Self {
Self { enabled: true, deadline: None, sleep: None, last_socket_activity_touch: None }
}
const fn enabled() -> Self { Self { enabled: true, deadline: None, sleep: None, last_socket_activity_touch: None } }
fn mark_progress(&mut self) -> bool {
if !self.enabled {
@@ -528,7 +524,11 @@ fn wrap_timed_client_stream_if_needed(
None => stream,
Some(mins) => {
let secs = u32::try_from((u64::from(mins) * 60).min(u64::from(u32::MAX))).unwrap_or(0);
if secs > 0 { TimedClientStream::new(app_state, stream, secs, addr, virtual_id).boxed() } else { stream }
if secs > 0 {
TimedClientStream::new(app_state, stream, secs, addr, virtual_id).boxed()
} else {
stream
}
}
}
}
@@ -567,7 +567,9 @@ fn create_deferred_provider_open_future(
client_ip: Some(&fingerprint.client_ip),
stream_channel: Some(stream_channel),
connect_failure_stage: Some(FailureStage::ProviderOpen),
});
content_representation: stream_details.content_representation,
})
.for_deferred_open();
provider_stream_factory_options.set_provider(input.get_resolve_provider(stream_url.as_ref()));
Some(DeferredProviderOpenState::Pending(Box::new(DeferredProviderOpenContext {
@@ -797,9 +799,7 @@ impl Stream for ActiveClientStream {
}
impl Drop for ActiveClientStream {
fn drop(&mut self) {
self.state.release_stream_and_provider_handle_once();
}
fn drop(&mut self) { self.state.release_stream_and_provider_handle_once(); }
}
#[allow(clippy::too_many_lines)]
@@ -1334,18 +1334,20 @@ fn stream_grace_period(request: GracePeriodParams) -> (Option<Arc<AtomicU8>>, Op
#[cfg(test)]
mod tests {
use super::{
ActiveClientStream, ActiveClientStreamParams, ActiveClientStreamState, CustomVideoBuffers,
DIRECT_BODY_IDLE_TIMEOUT_SECS, DeferredProviderOpenOutcome, DeferredProviderOpenState, DirectBodyIdleTimeout,
GracePeriodParams, StreamMode, TimedStreamContext, create_active_client_stream,
should_use_direct_body_idle_timeout, stream_grace_period,
create_active_client_stream, create_deferred_provider_open_future, should_use_direct_body_idle_timeout,
stream_grace_period, ActiveClientStream, ActiveClientStreamParams, ActiveClientStreamState, CustomVideoBuffers,
DeferredProviderOpenOutcome, DeferredProviderOpenState, DirectBodyIdleTimeout, GracePeriodParams, StreamMode,
TimedStreamContext, DIRECT_BODY_IDLE_TIMEOUT_SECS,
};
use crate::api::api_utils::GraceResolutionContext;
use crate::api::model::connection_manager::PROVIDER_END_NOT_SET;
use crate::{
api::model::{
ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager,
CreateUserSessionParams, CustomVideoStreamType, DownloadQueue, EventManager, MetadataUpdateManager,
PlaylistStorageState, SharedStreamManager, StreamDetails, StreamError, UpdateGuard,
api::{
api_utils::GraceResolutionContext,
model::{
connection_manager::PROVIDER_END_NOT_SET, ActiveProviderManager, ActiveUserManager, AppState,
CancelTokens, ConnectionManager, CreateUserSessionParams, CustomVideoStreamType, DownloadQueue,
EventManager, MetadataUpdateManager, PlaylistStorageState, ProviderContentRepresentationMode,
SharedStreamManager, StreamDetails, StreamError, UpdateGuard,
},
},
auth::Fingerprint,
model::{
@@ -1357,7 +1359,7 @@ mod tests {
use arc_swap::{ArcSwap, ArcSwapOption};
use axum::http::HeaderMap;
use bytes::Bytes;
use futures::{StreamExt, pin_mut};
use futures::{pin_mut, StreamExt};
use reqwest::Client;
use shared::{
model::{
@@ -1369,8 +1371,8 @@ mod tests {
use std::{
collections::HashMap,
sync::{
Arc,
atomic::{AtomicU8, Ordering},
Arc,
},
time::Duration,
};
@@ -1610,15 +1612,12 @@ mod tests {
provider_name: Some(Arc::clone(provider_name)),
request_url: Some("http://provider-1.example/live/1".intern()),
session_headers: None,
grace_period: GracePeriodOptions {
period_millis: 100,
timeout_secs: 0,
hold_stream: true,
},
grace_period: GracePeriodOptions { period_millis: 100, timeout_secs: 0, hold_stream: true },
provider_grace_active: true,
disable_provider_grace: false,
reconnect_flag: None,
provider_handle: Some(provider_handle),
content_representation: ProviderContentRepresentationMode::PreserveOrigin,
grace_resolution_context: None,
}
}
@@ -1979,6 +1978,49 @@ mod tests {
app_state.connection_manager.release_provider_handle(Some(deferred_handle)).await;
}
#[tokio::test]
async fn deferred_provider_open_forces_identity_when_original_mode_preserves_origin() {
let app_state = create_test_app_state();
let provider_name = "provider_1".intern();
let addr = "127.0.0.1:55015".parse().unwrap();
let provider_handle = app_state
.active_provider
.acquire_exact_connection_with_grace(
&provider_name,
&addr,
true,
0,
crate::api::model::ConnectionKind::Normal,
)
.await
.expect("deferred provider allocation");
let stream_details = create_deferred_provider_grace_details(&provider_name, provider_handle.clone());
assert_eq!(stream_details.content_representation, ProviderContentRepresentationMode::PreserveOrigin);
let fingerprint = create_test_fingerprint(addr);
let mut stream_channel = create_test_stream_channel(1, "http://provider-1.example/live/1");
stream_channel.item_type = PlaylistItemType::Catchup;
let deferred = create_deferred_provider_open_future(
&app_state,
&stream_details,
&fingerprint,
&stream_channel,
&HeaderMap::new(),
)
.expect("deferred provider open context");
let DeferredProviderOpenState::Pending(context) = deferred else {
panic!("new deferred open must start pending")
};
assert_eq!(
context.provider_stream_factory_options.content_representation(),
ProviderContentRepresentationMode::Identity
);
assert!(!context.provider_stream_factory_options.response_head_is_available());
drop(context);
app_state.connection_manager.release_provider_handle(Some(provider_handle)).await;
}
#[tokio::test(start_paused = true)]
async fn test_active_client_stream_deferred_provider_grace_retains_provider_handle_while_grace_pending() {
let app_state = create_test_app_state();
@@ -2472,15 +2514,12 @@ mod tests {
provider_name: Some(provider_name),
request_url: Some("http://provider-1.example/live/2.ts".intern()),
session_headers: None,
grace_period: GracePeriodOptions {
period_millis: 100,
timeout_secs: 0,
hold_stream: true,
},
grace_period: GracePeriodOptions { period_millis: 100, timeout_secs: 0, hold_stream: true },
provider_grace_active: false,
disable_provider_grace: false,
reconnect_flag: None,
provider_handle: None,
content_representation: ProviderContentRepresentationMode::PreserveOrigin,
grace_resolution_context: Some(grace_context.clone()),
};
@@ -13,8 +13,7 @@ use axum::response::IntoResponse;
use log::trace;
use reqwest::StatusCode;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use shared::error::TuliproxError;
use shared::model::PlaylistItemType;
use shared::{error::TuliproxError, model::PlaylistItemType};
use std::{fmt, net::SocketAddr, str::FromStr, sync::Arc};
use tokio_util::sync::CancellationToken;
@@ -87,7 +86,8 @@ fn prepare_video_headers(headers: &[(String, String)]) -> Vec<(String, String)>
|| key.eq_ignore_ascii_case("content-length")
|| key.eq_ignore_ascii_case("range")
|| key.eq_ignore_ascii_case("content-range")
|| key.eq_ignore_ascii_case("accept-ranges"))
|| key.eq_ignore_ascii_case("accept-ranges")
|| key.eq_ignore_ascii_case("transfer-encoding"))
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
@@ -146,12 +146,11 @@ fn create_video_stream(
}
if let Some(video) = video_buffer {
trace!("{log_message}");
let stream =
apply_custom_stream_timeout(cfg, Box::pin(ThrottledStream::new(CustomVideoStream::new(video.clone()), 8000)));
(
Some(stream),
Some((prepare_video_headers(headers), status, None, Some(stream_type))),
)
let stream = apply_custom_stream_timeout(
cfg,
Box::pin(ThrottledStream::new(CustomVideoStream::new(video.clone()), 8000)),
);
(Some(stream), Some((prepare_video_headers(headers), status, None, Some(stream_type))))
} else {
(None, None)
}
@@ -190,10 +189,7 @@ pub fn create_channel_unavailable_stream(
/// and human-readable description.
macro_rules! ok_custom_stream_factory {
($fn_name:ident, $field:ident, $stream_type:expr, $description:expr) => {
pub fn $fn_name(
cfg: &AppConfig,
headers: &[(String, String)],
) -> ProviderStreamResponse {
pub fn $fn_name(cfg: &AppConfig, headers: &[(String, String)]) -> ProviderStreamResponse {
let custom_stream_response = cfg.custom_stream_response.load();
let video = custom_stream_response.as_ref().and_then(|c| c.$field.as_ref());
create_ok_video_stream(cfg, $stream_type, video, headers, $description)
@@ -273,9 +269,7 @@ pub fn create_custom_video_stream_response(
) -> impl axum::response::IntoResponse + Send {
let config = &app_state.app_config;
if let (Some(stream), Some((headers, status_code, _, _))) = match video_response {
CustomVideoStreamType::ChannelUnavailable => {
create_channel_unavailable_stream(config, &[], StatusCode::OK)
}
CustomVideoStreamType::ChannelUnavailable => create_channel_unavailable_stream(config, &[], StatusCode::OK),
CustomVideoStreamType::UserConnectionsExhausted => create_user_connections_exhausted_stream(config, &[]),
CustomVideoStreamType::ProviderConnectionsExhausted => {
create_provider_connections_exhausted_stream(config, &[])
@@ -316,8 +310,8 @@ pub fn get_header_filter_for_item_type(item_type: PlaylistItemType) -> HeaderFil
#[cfg(test)]
mod tests {
use super::{
create_channel_unavailable_stream, is_custom_video_stream_enabled, get_custom_stream_response_error_status,
CustomVideoStreamType,
create_channel_unavailable_stream, get_custom_stream_response_error_status, is_custom_video_stream_enabled,
prepare_video_headers, CustomVideoStreamType,
};
use crate::{
api::model::TransportStreamBuffer,
@@ -351,7 +345,10 @@ mod tests {
let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() };
let app_cfg = AppConfig {
config: Arc::new(ArcSwap::from_pointee(Config { custom_stream_response_enabled: true, ..Config::default()})),
config: Arc::new(ArcSwap::from_pointee(Config {
custom_stream_response_enabled: true,
..Config::default()
})),
sources: Arc::new(ArcSwap::from_pointee(sources)),
hdhomerun: Arc::new(ArcSwapOption::default()),
api_proxy: Arc::new(ArcSwapOption::default()),
@@ -404,6 +401,17 @@ mod tests {
assert_eq!(parsed.to_string(), "hls_session_or_lease_expired");
}
#[test]
fn custom_video_headers_drop_transfer_encoding() {
let headers = prepare_video_headers(&[
("Transfer-Encoding".to_string(), "chunked".to_string()),
("cache-control".to_string(), "no-store".to_string()),
]);
assert!(headers.iter().all(|(name, _)| !name.eq_ignore_ascii_case("transfer-encoding")));
assert!(headers.iter().any(|(name, value)| name == "cache-control" && value == "no-store"));
}
#[tokio::test]
async fn test_channel_unavailable_preserves_supplied_status_code() {
let app_cfg = create_test_app_config_with_channel_unavailable();
File diff suppressed because it is too large Load Diff
+2
View File
@@ -4074,6 +4074,7 @@ pub fn create_panel_api_provisioning_stream_details(
disable_provider_grace: true,
reconnect_flag: None,
provider_handle: None,
content_representation: crate::api::model::ProviderContentRepresentationMode::PreserveOrigin,
grace_resolution_context: None,
};
}
@@ -4100,6 +4101,7 @@ pub fn create_panel_api_provisioning_stream_details(
disable_provider_grace: true,
reconnect_flag: None,
provider_handle: None,
content_representation: crate::api::model::ProviderContentRepresentationMode::PreserveOrigin,
grace_resolution_context: None,
}
}
@@ -1,11 +1,12 @@
use super::rewrite_hls_url;
use crate::api::model::{
ProxySessionId, TransientResourceId, TransientResourceKind, TransientResourceRef,
HLS_ACCESS_LEASE_ID_PLACEHOLDER,
use crate::{
api::model::{
ProxySessionId, TransientResourceId, TransientResourceKind, TransientResourceRef,
HLS_ACCESS_LEASE_ID_PLACEHOLDER,
},
model::StripConfig,
};
use crate::model::StripConfig;
use shared::model::HlsStripMode;
use shared::utils::CONSTANTS;
use shared::{model::HlsStripMode, utils::CONSTANTS};
use std::{collections::HashMap, time::Duration};
use url::Url;
@@ -83,20 +84,14 @@ impl TransientManifestRewriter {
}
if body.is_empty() {
return TransientRewriteResult {
body: rewritten_body,
resources: Vec::new(),
};
return TransientRewriteResult { body: rewritten_body, resources: Vec::new() };
}
if let Some(discontinuity_sequence) = options.handoff_discontinuity_sequence {
rewritten_body = apply_handoff_discontinuity_boundary(&rewritten_body, discontinuity_sequence);
}
TransientRewriteResult {
body: rewritten_body,
resources: resources.into_values().collect(),
}
TransientRewriteResult { body: rewritten_body, resources: resources.into_values().collect() }
}
}
@@ -142,12 +137,8 @@ pub fn materialize_transient_provisioning_handoff_view(
&previous_units,
previous_units.len().saturating_sub(selected_previous_units.len()),
);
let mut rewritten = String::with_capacity(
origin_body
.len()
.saturating_add(previous_provisioning_body.len())
.saturating_add(64),
);
let mut rewritten =
String::with_capacity(origin_body.len().saturating_add(previous_provisioning_body.len()).saturating_add(64));
for line in &origin_lines[..first_origin_unit.start] {
if line.line.trim().starts_with("#EXT-X-MEDIA-SEQUENCE:") {
if let Some(media_sequence) = media_sequence {
@@ -213,19 +204,12 @@ pub fn apply_transient_discontinuity_sequence(body: &str, discontinuity_sequence
}
pub fn transient_discontinuity_sequence(body: &str) -> Option<u64> {
manifest_lines(body)
.iter()
.find_map(|line| parse_discontinuity_sequence(line.line))
manifest_lines(body).iter().find_map(|line| parse_discontinuity_sequence(line.line))
}
pub fn transient_visible_discontinuity_count(body: &str) -> u64 {
u64::try_from(
manifest_lines(body)
.iter()
.filter(|line| is_discontinuity_tag(line.line.trim()))
.count(),
)
.unwrap_or(u64::MAX)
u64::try_from(manifest_lines(body).iter().filter(|line| is_discontinuity_tag(line.line.trim())).count())
.unwrap_or(u64::MAX)
}
fn rewrite_line(
@@ -268,6 +252,21 @@ fn rewrite_line(
);
}
if trimmed.starts_with("#EXT-X-PART:")
|| (trimmed.starts_with("#EXT-X-PRELOAD-HINT:") && trimmed.contains("TYPE=PART"))
{
return rewrite_uri_attribute(
line,
final_manifest_url,
proxy_session_id,
reverse_proxy_rewrite_secret,
now_ms,
ttl_ms,
resources,
TransientResourceKind::Part,
);
}
if trimmed.starts_with('#') && CONSTANTS.re_hls_uri.is_match(line) {
return rewrite_uri_attribute(
line,
@@ -323,10 +322,7 @@ fn rewrite_uri_attribute(
resources,
kind,
);
CONSTANTS
.re_hls_uri
.replace(line, format!(r#"URI="{proxy_uri}""#))
.to_string()
CONSTANTS.re_hls_uri.replace(line, format!(r#"URI="{proxy_uri}""#)).to_string()
}
#[allow(clippy::too_many_arguments)]
@@ -353,10 +349,7 @@ fn rewrite_resource_uri(
Some(extension.clone()),
);
let resource_id = resource.id.clone();
resources
.entry(resource_id.clone())
.and_modify(|existing| *existing = resource.clone())
.or_insert(resource);
resources.entry(resource_id.clone()).and_modify(|existing| *existing = resource.clone()).or_insert(resource);
(
format!(
"/hls/shared/live/{}/{}/r/{}.{}",
@@ -381,12 +374,8 @@ fn extract_extension(resolved_origin_uri: &str) -> Option<String> {
.filter(|extension| !extension.is_empty());
}
let without_query = resolved_origin_uri
.split_once('?')
.map_or(resolved_origin_uri, |(path, _)| path);
let without_fragment = without_query
.split_once('#')
.map_or(without_query, |(path, _)| path);
let without_query = resolved_origin_uri.split_once('?').map_or(resolved_origin_uri, |(path, _)| path);
let without_fragment = without_query.split_once('#').map_or(without_query, |(path, _)| path);
without_fragment
.rsplit_once('.')
.map(|(_, extension)| extension.to_string())
@@ -397,7 +386,7 @@ fn fallback_extension(kind: TransientResourceKind) -> &'static str {
match kind {
TransientResourceKind::Key => "key",
TransientResourceKind::Map => "mp4",
TransientResourceKind::Segment | TransientResourceKind::Other => "bin",
TransientResourceKind::Segment | TransientResourceKind::Part | TransientResourceKind::Other => "bin",
}
}
@@ -516,12 +505,7 @@ fn append_media_unit_with_duration_override(
}
}
fn append_gap_media_unit(
output: &mut String,
lines: &[ManifestLine<'_>],
unit: MediaSegmentUnit,
duration_ms: u64,
) {
fn append_gap_media_unit(output: &mut String, lines: &[ManifestLine<'_>], unit: MediaSegmentUnit, duration_ms: u64) {
output.push_str("#EXT-X-GAP\n");
let uri_index = lines[unit.start..=unit.end]
.iter()
@@ -551,9 +535,7 @@ fn append_manifest_block_separator(output: &mut String) {
}
fn media_unit_has_discontinuity(lines: &[ManifestLine<'_>], unit: MediaSegmentUnit) -> bool {
lines[unit.start..=unit.end]
.iter()
.any(|line| is_discontinuity_tag(line.line.trim()))
lines[unit.start..=unit.end].iter().any(|line| is_discontinuity_tag(line.line.trim()))
}
fn handoff_provisioning_media_sequence(
@@ -576,12 +558,9 @@ fn format_duration_ms(duration_ms: u64) -> String { format!("{}.{:03}", duration
fn apply_handoff_discontinuity_boundary(body: &str, handoff_discontinuity_sequence: u64) -> String {
let lines = manifest_lines(body);
let origin_discontinuity_sequence = lines
.iter()
.find_map(|line| parse_discontinuity_sequence(line.line))
.unwrap_or(0);
let effective_discontinuity_sequence =
origin_discontinuity_sequence.saturating_add(handoff_discontinuity_sequence);
let origin_discontinuity_sequence =
lines.iter().find_map(|line| parse_discontinuity_sequence(line.line)).unwrap_or(0);
let effective_discontinuity_sequence = origin_discontinuity_sequence.saturating_add(handoff_discontinuity_sequence);
let existing_sequence_index = lines.iter().position(|line| is_discontinuity_sequence_tag(line.line.trim()));
let insert_sequence_index = existing_sequence_index.unwrap_or_else(|| discontinuity_sequence_insert_index(&lines));
let first_segment = first_media_segment_boundary(&lines);
@@ -656,9 +635,7 @@ fn parse_discontinuity_sequence(line: &str) -> Option<u64> {
line.trim().strip_prefix("#EXT-X-DISCONTINUITY-SEQUENCE:")?.trim().parse().ok()
}
fn is_discontinuity_sequence_tag(line: &str) -> bool {
line.starts_with("#EXT-X-DISCONTINUITY-SEQUENCE:")
}
fn is_discontinuity_sequence_tag(line: &str) -> bool { line.starts_with("#EXT-X-DISCONTINUITY-SEQUENCE:") }
fn is_discontinuity_tag(line: &str) -> bool { line == "#EXT-X-DISCONTINUITY" }
@@ -670,9 +647,7 @@ fn duration_ms_from_extinf(value: &str) -> Option<u64> {
u64::try_from(Duration::from_secs_f64(seconds).as_millis()).ok()
}
fn is_segment_unit_tag(line: &str) -> bool {
is_media_segment_unit_tag(line)
}
fn is_segment_unit_tag(line: &str) -> bool { is_media_segment_unit_tag(line) }
fn is_media_segment_unit_tag(line: &str) -> bool {
line.starts_with("#EXTINF:")
@@ -694,14 +669,23 @@ mod tests {
apply_transient_discontinuity_sequence, materialize_transient_provisioning_handoff_view,
transient_discontinuity_sequence, TransientManifestRewriter, TransientRewriteOptions,
};
use crate::api::model::{build_transient_resource_id, ProxySessionId, TransientResourceKind};
use crate::model::StripConfig;
use shared::model::HlsStripMode;
use crate::{
api::model::{build_transient_resource_id, ProxySessionId, TransientResourceKind},
model::StripConfig,
};
use shared::model::HlsStripMode;
const BASE_URL: &str = "http://origin.example.com/live/final/index.m3u8";
fn rewrite(body: &str) -> crate::processing::parser::hls::transient_manifest::TransientRewriteResult {
TransientManifestRewriter::rewrite(body, BASE_URL, &ProxySessionId("proxy-id".to_string()), b"secret", 100, 1_000)
TransientManifestRewriter::rewrite(
body,
BASE_URL,
&ProxySessionId("proxy-id".to_string()),
b"secret",
100,
1_000,
)
}
fn rewrite_with_handoff(body: &str) -> crate::processing::parser::hls::transient_manifest::TransientRewriteResult {
@@ -712,9 +696,7 @@ use shared::model::HlsStripMode;
b"secret",
100,
1_000,
TransientRewriteOptions {
handoff_discontinuity_sequence: Some(0),
},
TransientRewriteOptions { handoff_discontinuity_sequence: Some(0) },
)
}
@@ -722,7 +704,9 @@ use shared::model::HlsStripMode;
fn ext_x_key_uri_is_rewritten_without_changing_other_attributes() {
let result = rewrite("#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"keys/key.bin\",IV=0x1\n#EXTINF:4.0,\nseg.ts\n");
assert!(result.body.contains("#EXT-X-KEY:METHOD=AES-128,URI=\"/hls/shared/live/proxy-id/__hls_access_lease_id__/r/"));
assert!(result
.body
.contains("#EXT-X-KEY:METHOD=AES-128,URI=\"/hls/shared/live/proxy-id/__hls_access_lease_id__/r/"));
assert!(result.body.contains(".bin\",IV=0x1"));
assert!(!result.body.contains("keys/key.bin"));
}
@@ -767,12 +751,16 @@ use shared::model::HlsStripMode;
}
#[test]
fn unsupported_uri_attributes_are_rewritten_to_transient_resources() {
fn low_latency_part_uri_is_rewritten_with_part_diagnostics_kind() {
let result = rewrite("#EXTM3U\n#EXT-X-PART:DURATION=1.0,URI=\"parts/part001.m4s\"\n");
assert!(result.body.contains("#EXT-X-PART:DURATION=1.0,URI=\"/hls/shared/live/proxy-id/__hls_access_lease_id__/r/"));
assert!(result
.body
.contains("#EXT-X-PART:DURATION=1.0,URI=\"/hls/shared/live/proxy-id/__hls_access_lease_id__/r/"));
assert!(result.body.contains(".m4s\""));
assert!(!result.body.contains("parts/part001.m4s"));
assert_eq!(result.resources.len(), 1);
assert_eq!(result.resources[0].kind, TransientResourceKind::Part);
}
#[test]
@@ -888,7 +876,9 @@ use shared::model::HlsStripMode;
assert!(body.contains("#EXT-X-DISCONTINUITY-SEQUENCE:0\n\n#EXTINF:2.000,"));
assert_eq!(body.matches("#EXTINF:").count(), 6);
assert!(body.contains("#EXTINF:2.000,\n/hls/shared/live/proxy-id/__hls_access_lease_id__/000002.ts?pseq=2"));
assert!(body.contains("\n#EXT-X-GAP\n#EXTINF:2.000,\n/hls/shared/live/proxy-id/__hls_access_lease_id__/000003.ts?pseq=3"));
assert!(body.contains(
"\n#EXT-X-GAP\n#EXTINF:2.000,\n/hls/shared/live/proxy-id/__hls_access_lease_id__/000003.ts?pseq=3"
));
assert!(body.contains("\n#EXT-X-DISCONTINUITY\n#EXTINF:1.92,"));
let gap = body.find("\n#EXT-X-GAP\n").expect("gap tag");
let discontinuity = body.find("\n#EXT-X-DISCONTINUITY\n#EXTINF:1.92,").expect("handoff discontinuity");
@@ -907,5 +897,4 @@ use shared::model::HlsStripMode;
}
fn strip_segments(value: u64) -> StripConfig { StripConfig { mode: HlsStripMode::Segments, value } }
}
+1
View File
@@ -428,6 +428,7 @@ fn process_header_internal(
}
}
plih.freeze_input_stream_id();
plih
}
+9 -5
View File
@@ -105,7 +105,7 @@ pub fn parse_xtream_series_info(parent_uuid: &UUIDType, series_info: &SeriesStre
}
PlaylistItem {
let mut item = PlaylistItem {
header: PlaylistItemHeader {
uuid: generate_provider_playlist_uuid(&input.name, &episode_id, PlaylistItemType::Series),
id: episode_id.into(),
@@ -124,8 +124,10 @@ pub fn parse_xtream_series_info(parent_uuid: &UUIDType, series_info: &SeriesStre
// Keep episode ordering tied to its parent SeriesInfo to avoid cross-series ordinal overlap.
source_ordinal: parent_source_ordinal,
..Default::default()
}
}
},
};
item.header.freeze_input_stream_id();
item
}).collect();
return if result.is_empty() { None } else { Some(result) };
}
@@ -199,7 +201,7 @@ pub async fn parse_xtream(input: &ConfigInput,
let category_name = &group.category_name;
let stream_url = create_xtream_url(xtream_cluster, url, username, password, &stream, live_stream_use_prefix, live_stream_without_extension);
let item_type = PlaylistItemType::from(xtream_cluster);
let item = PlaylistItem {
let mut item = PlaylistItem {
header: PlaylistItemHeader {
id: stream.get_stream_id().intern(),
uuid: generate_provider_playlist_uuid(&input_name, &stream.get_stream_id().to_string(), item_type),
@@ -217,6 +219,7 @@ pub async fn parse_xtream(input: &ConfigInput,
..Default::default()
},
};
item.header.freeze_input_stream_id();
group.add(item);
}
@@ -371,7 +374,7 @@ where
let stream_url = create_xtream_url(cluster, url, username, password, &stream, live_stream_use_prefix, live_stream_without_extension);
let item_type = PlaylistItemType::from(cluster);
let item = PlaylistItem {
let mut item = PlaylistItem {
header: PlaylistItemHeader {
id: stream.get_stream_id().intern(),
uuid: generate_provider_playlist_uuid(input_name, &stream.get_stream_id().to_string(), item_type),
@@ -390,6 +393,7 @@ where
..Default::default()
},
};
item.header.freeze_input_stream_id();
// if let Some(StreamProperties::Series(props)) = item.header.additional_properties.as_mut() {
// // We need to set category_id for Series properties just like parse_xtream might expect or use?
+188 -2
View File
@@ -1044,6 +1044,13 @@ fn execute_pipe<'a>(
};
let mut new_fpl = FetchedPlaylist { input: fpl.input, source, epg: fpl.epg.clone() };
// In-memory items are frozen here at the target-processing boundary. Read-only disk sources
// capture the same identity when their persisted M3U/Xtream items are converted to PlaylistItem.
if new_fpl.is_memory() {
for item in new_fpl.items_mut() {
item.header.freeze_input_stream_id();
}
}
if target.options.as_ref().is_some_and(|opt| opt.remove_duplicates) {
new_fpl.deduplicate(duplicates);
}
@@ -1664,15 +1671,194 @@ pub async fn exec_processing(
#[cfg(test)]
mod tests {
use super::*;
use shared::foundation::{get_filter, ValueProvider};
use shared::model::{ClusterFlags, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster};
use shared::foundation::{get_filter, MapperScript, ValueProvider};
use shared::model::{
ClusterFlags, ConfigRenameDto, ConfigTargetDto, M3uPlaylistItem, PlaylistEntry, PlaylistItem,
PlaylistItemHeader, PlaylistItemType, XtreamCluster, XtreamPlaylistItem,
};
use shared::utils::Internable;
fn serialize_without_trailing_input_stream_id<T: serde::Serialize>(value: &T) -> Vec<u8> {
let mut encoded = rmp_serde::to_vec(value).expect("playlist item should serialize");
assert_eq!(encoded.pop(), Some(0xa0), "input_stream_id should be the trailing empty string");
match encoded[0] {
marker @ 0x91..=0x9f => encoded[0] = marker - 1,
0xdc => {
let len = u16::from_be_bytes([encoded[1], encoded[2]]);
encoded[1..3].copy_from_slice(&(len - 1).to_be_bytes());
}
0xdd => {
let len = u32::from_be_bytes([encoded[1], encoded[2], encoded[3], encoded[4]]);
encoded[1..5].copy_from_slice(&(len - 1).to_be_bytes());
}
marker => panic!("unexpected MessagePack sequence marker {marker:#x}"),
}
encoded
}
fn item_with_props(props: StreamProperties) -> PlaylistItem {
let header = shared::model::PlaylistItemHeader { additional_properties: Some(props), ..Default::default() };
PlaylistItem { header }
}
#[test]
fn rename_preserves_input_stream_id_captured_at_target_boundary() {
let mut item = PlaylistItem {
header: PlaylistItemHeader {
id: "origin-alpha".intern(),
url: "http://provider.example/channel.m3u8".intern(),
..Default::default()
},
};
item.header.freeze_input_stream_id();
let rename = ConfigRename::from(&ConfigRenameDto {
field: ItemField::Url,
pattern: "provider".to_string(),
new_name: "target".to_string(),
t_pattern: None,
});
exec_rename(&mut item, Some(&vec![rename]));
assert_eq!(item.header.url.as_ref(), "http://target.example/channel.m3u8");
assert_eq!(item.header.input_stream_id.as_ref(), "origin-alpha");
}
#[test]
fn mapper_changes_id_without_changing_frozen_input_stream_id() {
let mut item = PlaylistItem {
header: PlaylistItemHeader {
id: "origin-alpha".intern(),
name: "Channel".intern(),
..Default::default()
},
};
item.header.freeze_input_stream_id();
let mapping = Mapping {
mapper: Some(vec![crate::model::Mapper {
filter: r#"name ~ ".*""#.to_string(),
script: r#"@id = "target-id""#.to_string(),
t_filter: Some(get_filter(r#"name ~ ".*""#, None).expect("filter should parse")),
t_script: Some(MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse")),
}]),
..Default::default()
};
let (mapped, _, matched) = map_channel(item, &mapping);
assert!(matched);
assert_eq!(mapped.header.id.as_ref(), "target-id");
assert_eq!(mapped.header.input_stream_id.as_ref(), "origin-alpha");
}
#[test]
fn mapper_cannot_resurrect_missing_legacy_input_stream_id_from_target_id() {
let mut source = PlaylistItem {
header: PlaylistItemHeader {
id: "80510".intern(),
url: "http://provider.example/live/user/pass/80510.ts".intern(),
input_name: "input".intern(),
item_type: PlaylistItemType::Live,
xtream_cluster: XtreamCluster::Live,
..Default::default()
},
};
source.header.freeze_input_stream_id();
let mut legacy_xtream = XtreamPlaylistItem::from(&source);
legacy_xtream.provider_id = 0;
legacy_xtream.input_stream_id = "".intern();
legacy_xtream.url = "http://provider.example/live/channel.m3u8".intern();
let mut legacy_item = PlaylistItem::from(&legacy_xtream);
legacy_item.header.freeze_input_stream_id();
let mapping = Mapping {
mapper: Some(vec![crate::model::Mapper {
filter: r#"name ~ ".*""#.to_string(),
script: r#"@id = "target-id""#.to_string(),
t_filter: Some(get_filter(r#"name ~ ".*""#, None).expect("filter should parse")),
t_script: Some(MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse")),
}]),
..Default::default()
};
let (mapped, _, matched) = map_channel(legacy_item, &mapping);
let materialized_m3u = M3uPlaylistItem::from(&mapped);
let materialized_xtream = XtreamPlaylistItem::from(&mapped);
assert!(matched);
assert_eq!(mapped.header.id.as_ref(), "target-id");
assert_eq!(mapped.get_input_stream_id(), None);
assert!(materialized_m3u.provider_id.is_empty());
assert_eq!(materialized_m3u.get_input_stream_id(), None);
assert_eq!(materialized_xtream.provider_id, 0);
assert_eq!(materialized_xtream.get_input_stream_id(), None);
}
#[test]
fn execute_pipe_freezes_input_stream_id_without_rename_or_mapper() {
let input = ConfigInput::default();
let item = PlaylistItem {
header: PlaylistItemHeader { id: "origin-alpha".intern(), ..Default::default() },
};
let source = MemoryPlaylistSource::new(vec![PlaylistGroup {
id: 1,
title: "Group".intern(),
channels: vec![item],
xtream_cluster: XtreamCluster::Live,
}])
.into_source();
let mut fetched = FetchedPlaylist { input: &input, source, epg: None };
let mut duplicates = HashSet::new();
let target = ConfigTarget::from(&ConfigTargetDto::default());
let mut processed = execute_pipe(&target, &vec![], &mut fetched, &mut duplicates, false)
.expect("target processing should succeed");
let mut groups = processed.source.take_groups();
assert_eq!(groups[0].channels[0].header.input_stream_id.as_ref(), "origin-alpha");
assert!(groups[0].channels[0].header.set_field("id", "late-target-id"));
assert_eq!(groups[0].channels[0].header.input_stream_id.as_ref(), "origin-alpha");
}
#[test]
fn legacy_messagepack_playlist_items_default_missing_input_stream_id() {
let mut source = PlaylistItem {
header: PlaylistItemHeader {
id: "origin-alpha".intern(),
url: "http://provider.example/live/user/pass/80510.ts".intern(),
input_name: "input".intern(),
item_type: PlaylistItemType::Live,
xtream_cluster: XtreamCluster::Live,
..Default::default()
},
};
let header_bytes = serialize_without_trailing_input_stream_id(&source.header);
let decoded_header: PlaylistItemHeader =
rmp_serde::from_slice(&header_bytes).expect("legacy header should deserialize");
assert!(decoded_header.input_stream_id.is_empty());
assert_eq!(decoded_header.get_input_stream_id(), None);
let mut decoded_header = decoded_header;
decoded_header.freeze_input_stream_id();
assert_eq!(decoded_header.get_input_stream_id().as_deref(), Some("origin-alpha"));
source.header.freeze_input_stream_id();
let mut m3u_item = M3uPlaylistItem::from(&source);
m3u_item.input_stream_id = "".intern();
let m3u_bytes = serialize_without_trailing_input_stream_id(&m3u_item);
let decoded_m3u: M3uPlaylistItem =
rmp_serde::from_slice(&m3u_bytes).expect("legacy M3U item should deserialize");
assert!(decoded_m3u.input_stream_id.is_empty());
assert_eq!(decoded_m3u.get_input_stream_id().as_deref(), Some("origin-alpha"));
let mut xtream_item = XtreamPlaylistItem::from(&source);
xtream_item.input_stream_id = "".intern();
let xtream_bytes = serialize_without_trailing_input_stream_id(&xtream_item);
let decoded_xtream: XtreamPlaylistItem =
rmp_serde::from_slice(&xtream_bytes).expect("legacy Xtream item should deserialize");
assert!(decoded_xtream.input_stream_id.is_empty());
assert_eq!(decoded_xtream.get_input_stream_id().as_deref(), Some("80510"));
}
#[test]
fn has_probe_details_requires_video_and_audio_for_video() {
let video = shared::model::VideoStreamProperties {
@@ -192,6 +192,7 @@ mod tests {
input_name: "lib".intern(),
channel_no: 0,
source_ordinal: 0,
input_stream_id: "1".intern(),
}
}
@@ -233,6 +234,7 @@ mod tests {
input_name: "lib".intern(),
channel_no: 0,
source_ordinal: 0,
input_stream_id: "2".intern(),
}
}
@@ -440,6 +440,7 @@ mod tests {
t_catchup_mode: None,
source_ordinal: 0,
additional_properties: None,
input_stream_id: "813294".intern(),
}
}
@@ -1158,6 +1158,7 @@ mod tests {
input_name: Arc::clone(&input_name),
channel_no: 0,
source_ordinal: 0,
input_stream_id: "9001".intern(),
};
let provider_parent_code = xtream_series_info.get_uuid().intern();
let xtream_provider_episode = XtreamPlaylistItem {
@@ -1179,6 +1180,7 @@ mod tests {
input_name,
channel_no: 0,
source_ordinal: 0,
input_stream_id: "201".intern(),
};
let provider_episode = PlaylistItem::from(&xtream_provider_episode);
let mut series_info = PlaylistItem::from(&xtream_series_info);
@@ -1716,6 +1716,7 @@ mod tests {
input_name: "input_a".intern(),
channel_no: 0,
source_ordinal: 0,
input_stream_id: provider_id.to_string().intern(),
}
}
@@ -1,8 +1,12 @@
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::Path;
use crate::utils::{
compression::compression_utils::{is_gzip, is_zlib_header},
file_reader, open_readonly_file,
};
use flate2::bufread::{GzDecoder, ZlibDecoder};
use crate::utils::compression::compression_utils::{is_deflate, is_gzip};
use crate::utils::{file_reader, open_readonly_file};
use std::{
io::{BufRead, BufReader, Read, Seek, SeekFrom},
path::Path,
};
pub struct CompressedFileReader {
reader: BufReader<Box<dyn Read>>,
@@ -19,38 +23,29 @@ impl CompressedFileReader {
let reader: Box<dyn Read> = if is_gzip(&header) {
Box::new(GzDecoder::new(buffered_file))
} else if is_deflate(&header) {
} else if is_zlib_header(&header) {
Box::new(ZlibDecoder::new(buffered_file))
} else {
Box::new(buffered_file)
};
Ok(Self {
reader: file_reader(reader),
})
Ok(Self { reader: file_reader(reader) })
}
}
// Implement the Read trait for CompressedFileReader
impl Read for CompressedFileReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.reader.read(buf)
}
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { self.reader.read(buf) }
}
// Implement BufRead for CompressedFileReader
impl BufRead for CompressedFileReader {
fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
self.reader.fill_buf()
}
fn fill_buf(&mut self) -> std::io::Result<&[u8]> { self.reader.fill_buf() }
fn consume(&mut self, amt: usize) {
self.reader.consume(amt);
}
fn consume(&mut self, amt: usize) { self.reader.consume(amt); }
}
impl Iterator for CompressedFileReader
{
impl Iterator for CompressedFileReader {
type Item = std::io::Result<String>;
fn next(&mut self) -> Option<Self::Item> {
@@ -1,12 +1,16 @@
use crate::utils::async_file_reader;
use crate::utils::compression::compression_utils::{is_deflate, is_gzip};
use crate::utils::{
async_file_reader,
compression::compression_utils::{is_gzip, is_zlib_header},
};
use async_compression::tokio::bufread::{GzipDecoder, ZlibDecoder};
use std::path::Path;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::fs::File;
use tokio::io::{
self, AsyncRead, AsyncReadExt, AsyncSeekExt, ReadBuf,
use std::{
path::Path,
pin::Pin,
task::{Context, Poll},
};
use tokio::{
fs::File,
io::{self, AsyncRead, AsyncReadExt, AsyncSeekExt, ReadBuf},
};
pub struct CompressedFileReaderAsync {
@@ -24,7 +28,7 @@ impl CompressedFileReaderAsync {
if is_gzip(&header) {
Ok(Self { reader: Box::new(GzipDecoder::new(buffered_file)) })
} else if is_deflate(&header) {
} else if is_zlib_header(&header) {
Ok(Self { reader: Box::new(ZlibDecoder::new(buffered_file)) })
} else {
Ok(Self { reader: Box::new(buffered_file) })
@@ -33,12 +37,7 @@ impl CompressedFileReaderAsync {
}
impl AsyncRead for CompressedFileReaderAsync {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.reader).poll_read(cx, buf)
}
}
@@ -1,15 +1,21 @@
use flate2::{read::GzDecoder, write::GzEncoder, Compression};
use std::io::{Read, Write};
use flate2::Compression;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
pub const fn is_gzip(bytes: &[u8]) -> bool {
// Gzip files start with the bytes 0x1F 0x8B
bytes.len() >= 2 && bytes[0] == 0x1F && bytes[1] == 0x8B
}
pub const fn is_deflate(bytes: &[u8]) -> bool {
bytes[0] == 0x78 && (bytes[1] == 0x01 || bytes[1] == 0x9C || bytes[1] == 0xDA)
/// Reports whether the prefix is a valid RFC 1950 zlib header.
pub const fn is_zlib_header(bytes: &[u8]) -> bool {
if bytes.len() < 2 {
return false;
}
let compression_method_and_info = bytes[0];
let flags = bytes[1];
let header = u16::from_be_bytes([compression_method_and_info, flags]);
compression_method_and_info & 0x0f == 8 && compression_method_and_info >> 4 <= 7 && header.rem_euclid(31) == 0
}
pub fn compress_string(input: &str) -> std::io::Result<Vec<u8>> {
@@ -24,3 +30,25 @@ pub fn decompress_string(input: &[u8]) -> std::io::Result<String> {
decoder.read_to_string(&mut decompressed)?;
Ok(decompressed)
}
#[cfg(test)]
mod tests {
use super::is_zlib_header;
#[test]
fn zlib_header_validation_covers_method_window_checksum_and_short_prefixes() {
for valid in [[0x78, 0x01], [0x78, 0x5e], [0x78, 0x9c], [0x78, 0xda]] {
assert!(is_zlib_header(&valid), "expected valid zlib header {valid:02x?}");
}
for invalid in [
&[][..],
&[0x78][..],
&[0x79, 0x18][..], // unsupported compression method
&[0x88, 0x1c][..], // CINFO exceeds the RFC 1950 maximum window size
&[0x78, 0x02][..], // invalid FCHECK remainder
] {
assert!(!is_zlib_header(invalid), "expected invalid zlib header {invalid:02x?}");
}
}
}
+1
View File
@@ -335,6 +335,7 @@ mod tests {
input_name: "input".intern(),
channel_no: 0,
source_ordinal: 0,
input_stream_id: "52568".intern(),
};
let value = to_human_readable_json_value(&item).expect("dump value should serialize");
+917
View File
@@ -0,0 +1,917 @@
use super::DynReader;
use crate::utils::compression::compression_utils::{is_gzip, is_zlib_header};
use async_compression::tokio::bufread::{BrotliDecoder, DeflateDecoder, GzipDecoder, ZlibDecoder, ZstdDecoder};
use futures::TryStreamExt;
use reqwest::{
header::{
self, HeaderMap, HeaderName, HeaderValue, ACCEPT_RANGES, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, ETAG,
TRANSFER_ENCODING, VARY,
},
StatusCode,
};
use std::{
error::Error as StdError,
io,
pin::Pin,
task::{Context, Poll},
};
use tokio::io::{AsyncRead, AsyncReadExt, BufReader, ReadBuf};
use tokio_util::io::StreamReader;
use url::Url;
const MAX_MAGIC_PREFIX_LEN: usize = 4;
const ZSTD_MAGIC: [u8; 4] = [0x28, 0xb5, 0x2f, 0xfd];
/// Controls the final `Accept-Encoding` representation requested from an origin.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum OutboundContentCodingPolicy {
#[default]
Inherit,
Identity,
}
/// Supported non-identity HTTP content codings.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ContentCoding {
Gzip,
Deflate,
Brotli,
Zstd,
}
impl ContentCoding {
/// Returns the fixed HTTP token used in redacted diagnostics.
pub(crate) const fn as_http_token(self) -> &'static str {
match self {
Self::Gzip => "gzip",
Self::Deflate => "deflate",
Self::Brotli => "br",
Self::Zstd => "zstd",
}
}
}
/// Safe fixed-field projection of an origin response whose coding was removed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ContentCodingObservation {
pub(crate) content_encoding: &'static str,
pub(crate) status: StatusCode,
pub(crate) content_length: Option<u64>,
}
/// Selects the narrowly scoped fallback detection used when `Content-Encoding` is absent.
///
/// The shared `Declared` prefix deliberately makes header precedence explicit at every call site.
#[allow(clippy::enum_variant_names)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum ContentCodingDetection {
/// Uses only declared `Content-Encoding` values.
#[default]
DeclaredOnly,
/// Preserves the historical generic-text fallback for headerless gzip and zlib only.
DeclaredOrLegacyTextMagic,
/// Detects the known headerless gzip, zlib, and Zstandard signatures accepted for HLS manifests.
DeclaredOrKnownHlsManifestMagic,
}
/// Origin response normalized to an asynchronous identity-representation body.
pub struct DecodedHttpResponse {
pub(crate) status: StatusCode,
pub(crate) final_url: Url,
pub(crate) headers: HeaderMap,
pub(crate) body: DynReader,
decoded_from: Vec<ContentCoding>,
original_content_length: Option<u64>,
}
impl DecodedHttpResponse {
/// Reports whether at least one non-identity content coding was removed.
pub(crate) fn was_content_decoded(&self) -> bool { !self.decoded_from.is_empty() }
/// Returns only fixed and numeric fields suitable for HLS origin diagnostics.
pub(crate) fn content_coding_observation(&self) -> Option<ContentCodingObservation> {
let content_encoding = match self.decoded_from.as_slice() {
[] => return None,
[coding] => coding.as_http_token(),
[_, ..] => "multiple",
};
Some(ContentCodingObservation {
content_encoding,
status: self.status,
content_length: self.original_content_length,
})
}
}
/// Errors raised while parsing or preparing an HTTP content-coding chain.
#[derive(Debug, thiserror::Error)]
pub(crate) enum ContentCodingError {
#[error("invalid Content-Encoding header")]
InvalidHeader,
#[error("unsupported Content-Encoding")]
Unsupported(String),
#[error("encoded partial content cannot be decoded safely")]
EncodedPartialContent,
#[error("failed to inspect encoded response prefix")]
PrefixRead(#[from] io::Error),
}
/// Reports that a declared `Content-Encoding` field is not a valid HTTP token list.
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
#[error("invalid Content-Encoding header")]
pub(crate) struct InvalidContentEncodingHeader;
/// Typed source attached to I/O failures emitted by a streaming decoder.
#[derive(Debug, thiserror::Error)]
#[error("content decoding failed: coding={coding:?}")]
pub(crate) struct ContentDecodingIoError {
pub(crate) coding: ContentCoding,
}
/// Errors raised while fully consuming a decoded, size-bounded text body.
#[derive(Debug, thiserror::Error)]
pub(crate) enum ContentBodyReadError {
#[error("decoded body exceeds configured limit {limit}")]
LimitExceeded { limit: usize },
#[error("decoded body is not valid UTF-8: valid_up_to={valid_up_to} error_len={error_len:?}")]
InvalidUtf8 { valid_up_to: usize, error_len: Option<usize> },
#[error(transparent)]
Io(#[from] io::Error),
}
pub(crate) fn force_accept_encoding_identity(headers: &mut HeaderMap) {
headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("identity"));
}
pub(crate) fn apply_outbound_content_coding_policy(headers: &mut HeaderMap, policy: OutboundContentCodingPolicy) {
if matches!(policy, OutboundContentCodingPolicy::Identity) {
force_accept_encoding_identity(headers);
}
}
/// Parses declared content-coding tokens without deciding whether Tuliprox can decode them.
pub(crate) fn parse_content_encoding_tokens(headers: &HeaderMap) -> Result<Vec<String>, InvalidContentEncodingHeader> {
let mut tokens = Vec::new();
for value in headers.get_all(CONTENT_ENCODING) {
let value = value.to_str().map_err(|_| InvalidContentEncodingHeader)?;
for token in value.split(',') {
let token = token.trim_matches(|character| matches!(character, ' ' | '\t'));
if !is_http_token(token) {
return Err(InvalidContentEncodingHeader);
}
tokens.push(token.to_owned());
}
}
Ok(tokens)
}
fn is_http_token(value: &str) -> bool {
!value.is_empty()
&& value.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
})
}
pub(crate) fn parse_content_codings(headers: &HeaderMap) -> Result<Vec<ContentCoding>, ContentCodingError> {
let mut codings = Vec::new();
for token in parse_content_encoding_tokens(headers).map_err(|_| ContentCodingError::InvalidHeader)? {
let coding = if token.eq_ignore_ascii_case("identity") {
None
} else if token.eq_ignore_ascii_case("gzip") || token.eq_ignore_ascii_case("x-gzip") {
Some(ContentCoding::Gzip)
} else if token.eq_ignore_ascii_case("deflate") {
Some(ContentCoding::Deflate)
} else if token.eq_ignore_ascii_case("br") {
Some(ContentCoding::Brotli)
} else if token.eq_ignore_ascii_case("zstd") {
Some(ContentCoding::Zstd)
} else {
return Err(ContentCodingError::Unsupported(token));
};
if let Some(coding) = coding {
codings.push(coding);
}
}
Ok(codings)
}
pub(crate) async fn decode_response_to_identity(
response: reqwest::Response,
detection: ContentCodingDetection,
) -> Result<DecodedHttpResponse, ContentCodingError> {
let status = response.status();
let final_url = response.url().clone();
let headers = response.headers().clone();
let original_content_length =
headers.get(CONTENT_LENGTH).and_then(|value| value.to_str().ok()).and_then(|value| value.parse::<u64>().ok());
let has_content_encoding = headers.contains_key(CONTENT_ENCODING);
let mut decoded_from = parse_content_codings(&headers)?;
let stream = response.bytes_stream().map_err(|error| {
let kind = if error.is_timeout() { io::ErrorKind::TimedOut } else { io::ErrorKind::Other };
io::Error::new(kind, error)
});
let mut body: DynReader = Box::pin(StreamReader::new(stream));
if !has_content_encoding && !matches!(detection, ContentCodingDetection::DeclaredOnly) {
let (replayed_body, prefix) = inspect_prefix(body, MAX_MAGIC_PREFIX_LEN).await?;
body = replayed_body;
let detected = match detection {
ContentCodingDetection::DeclaredOnly => None,
ContentCodingDetection::DeclaredOrLegacyTextMagic => coding_from_legacy_text_magic(&prefix),
ContentCodingDetection::DeclaredOrKnownHlsManifestMagic => coding_from_known_manifest_magic(&prefix),
};
if let Some(coding) = detected {
decoded_from.push(coding);
}
}
if status == StatusCode::PARTIAL_CONTENT && !decoded_from.is_empty() {
return Err(ContentCodingError::EncodedPartialContent);
}
for coding in decoded_from.iter().rev().copied() {
body = decoder_for(body, coding).await?;
}
let mut decoded = DecodedHttpResponse { status, final_url, headers, body, decoded_from, original_content_length };
if decoded.was_content_decoded() {
normalize_headers_after_content_decoding(&mut decoded.headers);
} else if has_content_encoding {
decoded.headers.remove(CONTENT_ENCODING);
}
Ok(decoded)
}
pub(crate) async fn read_to_end_limited<R>(reader: &mut R, max_bytes: usize) -> Result<Vec<u8>, ContentBodyReadError>
where
R: AsyncRead + Unpin,
{
let probe_limit = max_bytes.checked_add(1);
let mut result = Vec::with_capacity(max_bytes.min(8 * 1024));
let mut buffer = [0_u8; 8 * 1024];
loop {
let read_len = probe_limit.map_or(buffer.len(), |limit| limit.saturating_sub(result.len()).min(buffer.len()));
if read_len == 0 {
return Err(ContentBodyReadError::LimitExceeded { limit: max_bytes });
}
let read = reader.read(&mut buffer[..read_len]).await?;
if read == 0 {
return Ok(result);
}
result.extend_from_slice(&buffer[..read]);
if result.len() > max_bytes {
return Err(ContentBodyReadError::LimitExceeded { limit: max_bytes });
}
}
}
pub(crate) async fn read_utf8_limited<R>(reader: &mut R, max_bytes: usize) -> Result<String, ContentBodyReadError>
where
R: AsyncRead + Unpin,
{
let bytes = read_to_end_limited(reader, max_bytes).await?;
String::from_utf8(bytes).map_err(|error| {
let utf8_error = error.utf8_error();
ContentBodyReadError::InvalidUtf8 { valid_up_to: utf8_error.valid_up_to(), error_len: utf8_error.error_len() }
})
}
pub(crate) fn normalize_headers_after_content_decoding(headers: &mut HeaderMap) {
for name in [
CONTENT_ENCODING,
CONTENT_LENGTH,
CONTENT_RANGE,
ACCEPT_RANGES,
ETAG,
HeaderName::from_static("content-md5"),
HeaderName::from_static("digest"),
HeaderName::from_static("content-digest"),
HeaderName::from_static("repr-digest"),
TRANSFER_ENCODING,
] {
headers.remove(name);
}
remove_accept_encoding_from_vary(headers);
}
pub(crate) fn content_decoding_error_from_io(error: &io::Error) -> Option<&ContentDecodingIoError> {
find_content_decoding_error(error.get_ref()?)
}
async fn decoder_for(mut reader: DynReader, coding: ContentCoding) -> Result<DynReader, ContentCodingError> {
let reader = match coding {
ContentCoding::Gzip => tagged_decoder(GzipDecoder::new(BufReader::new(reader)), coding),
ContentCoding::Brotli => tagged_decoder(BrotliDecoder::new(BufReader::new(reader)), coding),
ContentCoding::Zstd => tagged_decoder(ZstdDecoder::new(BufReader::new(reader)), coding),
ContentCoding::Deflate => {
let (replayed_reader, prefix) = inspect_prefix(reader, 2).await?;
reader = replayed_reader;
if is_zlib_header(&prefix) {
tagged_decoder(ZlibDecoder::new(BufReader::new(reader)), coding)
} else {
tagged_decoder(DeflateDecoder::new(BufReader::new(reader)), coding)
}
}
};
Ok(reader)
}
fn tagged_decoder<R>(reader: R, coding: ContentCoding) -> DynReader
where
R: AsyncRead + Send + Unpin + 'static,
{
Box::pin(ContentDecodingReader { inner: reader, coding })
}
async fn inspect_prefix(mut reader: DynReader, max_len: usize) -> io::Result<(DynReader, Vec<u8>)> {
let mut prefix = Vec::with_capacity(max_len);
while prefix.len() < max_len {
let mut chunk = [0_u8; MAX_MAGIC_PREFIX_LEN];
let remaining = max_len - prefix.len();
let read = reader.read(&mut chunk[..remaining]).await?;
if read == 0 {
break;
}
prefix.extend_from_slice(&chunk[..read]);
}
let replayed = std::io::Cursor::new(prefix.clone()).chain(reader);
Ok((Box::pin(replayed), prefix))
}
fn coding_from_known_manifest_magic(prefix: &[u8]) -> Option<ContentCoding> {
coding_from_legacy_text_magic(prefix).or_else(|| prefix.starts_with(&ZSTD_MAGIC).then_some(ContentCoding::Zstd))
}
fn coding_from_legacy_text_magic(prefix: &[u8]) -> Option<ContentCoding> {
if is_gzip(prefix) {
Some(ContentCoding::Gzip)
} else {
is_zlib_header(prefix).then_some(ContentCoding::Deflate)
}
}
fn remove_accept_encoding_from_vary(headers: &mut HeaderMap) {
let values = headers.get_all(VARY).iter().cloned().collect::<Vec<_>>();
if values.is_empty() {
return;
}
headers.remove(VARY);
for value in values {
let original_value = value.clone();
let Ok(value) = value.to_str() else {
headers.append(VARY, original_value);
continue;
};
let remaining = value
.split(',')
.map(str::trim)
.filter(|token| !token.eq_ignore_ascii_case("accept-encoding"))
.collect::<Vec<_>>();
if !remaining.is_empty() {
match HeaderValue::from_str(&remaining.join(", ")) {
Ok(value) => {
headers.append(VARY, value);
}
Err(_) => {
headers.append(VARY, original_value);
}
}
}
}
}
fn find_content_decoding_error<'a>(mut error: &'a (dyn StdError + 'static)) -> Option<&'a ContentDecodingIoError> {
loop {
if let Some(error) = error.downcast_ref::<ContentDecodingIoError>() {
return Some(error);
}
error = error.source()?;
}
}
struct ContentDecodingReader<R> {
inner: R,
coding: ContentCoding,
}
impl<R> AsyncRead for ContentDecodingReader<R>
where
R: AsyncRead + Unpin,
{
fn poll_read(
mut self: Pin<&mut Self>,
context: &mut Context<'_>,
buffer: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
match Pin::new(&mut self.inner).poll_read(context, buffer) {
Poll::Ready(Err(error))
if content_decoding_error_from_io(&error).is_none() && !is_http_body_transport_error(&error) =>
{
let kind = error.kind();
Poll::Ready(Err(io::Error::new(kind, ContentDecodingIoError { coding: self.coding })))
}
result => result,
}
}
}
pub(crate) fn is_http_body_transport_error(error: &io::Error) -> bool {
let Some(source) = error.get_ref() else {
return false;
};
let mut source: &(dyn StdError + 'static) = source;
loop {
if source.downcast_ref::<reqwest::Error>().is_some() {
return true;
}
let Some(next) = source.source() else {
return false;
};
source = next;
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_compression::tokio::bufread::{BrotliEncoder, DeflateEncoder, GzipEncoder, ZlibEncoder, ZstdEncoder};
use reqwest::redirect::Policy;
use std::io::Cursor;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
const MANIFEST: &[u8] = b"#EXTM3U\n#EXT-X-VERSION:3\nsegment.ts\n";
#[test]
fn content_coding_parses_all_headers_tokens_aliases_and_identity() {
let mut headers = HeaderMap::new();
headers.append(CONTENT_ENCODING, HeaderValue::from_static("gzip, identity"));
headers.append(CONTENT_ENCODING, HeaderValue::from_static("BR, x-GZiP, deflate, zstd"));
assert_eq!(
parse_content_codings(&headers).expect("valid codings"),
vec![
ContentCoding::Gzip,
ContentCoding::Brotli,
ContentCoding::Gzip,
ContentCoding::Deflate,
ContentCoding::Zstd,
]
);
}
#[test]
fn content_coding_rejects_empty_invalid_and_unsupported_tokens() {
for value in [HeaderValue::from_static("gzip,"), HeaderValue::from_static("gzip; level=1")] {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_ENCODING, value);
assert!(matches!(parse_content_codings(&headers), Err(ContentCodingError::InvalidHeader)));
}
let mut headers = HeaderMap::new();
headers.insert(CONTENT_ENCODING, HeaderValue::from_bytes(&[0xff]).expect("opaque header value"));
assert!(matches!(parse_content_codings(&headers), Err(ContentCodingError::InvalidHeader)));
let mut headers = HeaderMap::new();
headers.insert(CONTENT_ENCODING, HeaderValue::from_static("compress"));
assert!(matches!(
parse_content_codings(&headers),
Err(ContentCodingError::Unsupported(coding)) if coding == "compress"
));
}
#[test]
fn content_encoding_token_parser_preserves_unknown_valid_tokens_and_order() {
let mut headers = HeaderMap::new();
headers.append(CONTENT_ENCODING, HeaderValue::from_static("gzip, X-Provider-Coding"));
headers.append(CONTENT_ENCODING, HeaderValue::from_static("compress"));
assert_eq!(
parse_content_encoding_tokens(&headers).expect("valid HTTP tokens"),
vec!["gzip".to_owned(), "X-Provider-Coding".to_owned(), "compress".to_owned()]
);
assert!(matches!(
parse_content_codings(&headers),
Err(ContentCodingError::Unsupported(coding)) if coding == "X-Provider-Coding"
));
}
#[test]
fn content_coding_identity_policy_overrides_existing_value_only_when_requested() {
let mut headers = HeaderMap::new();
headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip, br"));
apply_outbound_content_coding_policy(&mut headers, OutboundContentCodingPolicy::Inherit);
assert_eq!(headers[header::ACCEPT_ENCODING], "gzip, br");
apply_outbound_content_coding_policy(&mut headers, OutboundContentCodingPolicy::Identity);
assert_eq!(headers[header::ACCEPT_ENCODING], "identity");
}
#[tokio::test]
async fn content_coding_decodes_all_declared_codings_and_deflate_wrappers() {
let cases = [
("gzip", Encoding::Gzip),
("x-gzip", Encoding::Gzip),
("deflate", Encoding::Zlib),
("deflate", Encoding::RawDeflate),
("br", Encoding::Brotli),
("zstd", Encoding::Zstd),
];
for (header_value, encoding) in cases {
let encoded = encode(MANIFEST, encoding).await;
let response = local_response(StatusCode::OK, &[("Content-Encoding", header_value)], encoded).await;
let mut decoded = decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly)
.await
.expect("decoder setup");
assert_eq!(
read_to_end_limited(&mut decoded.body, 1024).await.expect("decoded body"),
MANIFEST,
"failed for {header_value} / {encoding:?}"
);
assert!(!decoded.headers.contains_key(CONTENT_ENCODING));
}
}
#[tokio::test]
async fn content_coding_decodes_multiple_header_lines_in_reverse_application_order() {
let gzip = encode(MANIFEST, Encoding::Gzip).await;
let brotli_over_gzip = encode(&gzip, Encoding::Brotli).await;
let encoded_length = brotli_over_gzip.len() as u64;
let response = local_response(
StatusCode::OK,
&[("Content-Encoding", "gzip"), ("Content-Encoding", "br")],
brotli_over_gzip,
)
.await;
let mut decoded =
decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly).await.expect("decoder setup");
assert_eq!(decoded.decoded_from, vec![ContentCoding::Gzip, ContentCoding::Brotli]);
assert_eq!(
decoded.content_coding_observation(),
Some(ContentCodingObservation {
content_encoding: "multiple",
status: StatusCode::OK,
content_length: Some(encoded_length),
})
);
assert_eq!(read_to_end_limited(&mut decoded.body, 1024).await.expect("decoded body"), MANIFEST);
}
#[tokio::test]
async fn content_coding_decodes_comma_separated_codings_in_reverse_application_order() {
let gzip = encode(MANIFEST, Encoding::Gzip).await;
let brotli_over_gzip = encode(&gzip, Encoding::Brotli).await;
let response = local_response(StatusCode::OK, &[("Content-Encoding", "gzip, br")], brotli_over_gzip).await;
let mut decoded =
decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly).await.expect("decoder setup");
assert_eq!(decoded.decoded_from, vec![ContentCoding::Gzip, ContentCoding::Brotli]);
assert_eq!(read_to_end_limited(&mut decoded.body, 1024).await.expect("decoded body"), MANIFEST);
}
#[tokio::test]
async fn content_coding_declared_value_wins_over_contradicting_magic() {
for detection in
[ContentCodingDetection::DeclaredOrLegacyTextMagic, ContentCodingDetection::DeclaredOrKnownHlsManifestMagic]
{
let gzip = encode(MANIFEST, Encoding::Gzip).await;
let response = local_response(StatusCode::OK, &[("Content-Encoding", "br")], gzip).await;
let mut decoded = decode_response_to_identity(response, detection).await.expect("lazy decoder setup");
let error = read_to_end_limited(&mut decoded.body, 1024)
.await
.expect_err("gzip bytes must not override declared Brotli");
let ContentBodyReadError::Io(error) = error else {
panic!("expected decoder I/O error");
};
assert_eq!(
content_decoding_error_from_io(&error).expect("typed decoder error").coding,
ContentCoding::Brotli
);
}
}
#[tokio::test]
async fn content_coding_legacy_text_magic_decodes_only_headerless_gzip_and_zlib() {
for encoding in [Encoding::Gzip, Encoding::Zlib] {
let encoded = encode(MANIFEST, encoding).await;
let response = local_response(StatusCode::OK, &[], encoded).await;
let mut decoded = decode_response_to_identity(response, ContentCodingDetection::DeclaredOrLegacyTextMagic)
.await
.expect("decoder setup");
assert_eq!(
read_to_end_limited(&mut decoded.body, 1024).await.expect("magic-decoded body"),
MANIFEST,
"failed for {encoding:?}"
);
assert!(decoded.was_content_decoded());
}
for encoding in [Encoding::RawDeflate, Encoding::Brotli, Encoding::Zstd] {
let encoded = encode(MANIFEST, encoding).await;
let response = local_response(StatusCode::OK, &[], encoded.clone()).await;
let mut untouched =
decode_response_to_identity(response, ContentCodingDetection::DeclaredOrLegacyTextMagic)
.await
.expect("identity setup");
assert!(!untouched.was_content_decoded(), "must not guess {encoding:?}");
assert_eq!(read_to_end_limited(&mut untouched.body, 1024).await.expect("untouched body"), encoded);
}
}
#[tokio::test]
async fn content_coding_hls_manifest_magic_supports_only_known_signatures() {
for encoding in [Encoding::Gzip, Encoding::Zlib, Encoding::Zstd] {
let encoded = encode(MANIFEST, encoding).await;
let response = local_response(StatusCode::OK, &[], encoded).await;
let mut decoded =
decode_response_to_identity(response, ContentCodingDetection::DeclaredOrKnownHlsManifestMagic)
.await
.expect("decoder setup");
assert_eq!(
read_to_end_limited(&mut decoded.body, 1024).await.expect("magic-decoded body"),
MANIFEST,
"failed for {encoding:?}"
);
}
let encoded_binary = encode(b"not a manifest", Encoding::Gzip).await;
let response = local_response(StatusCode::OK, &[], encoded_binary.clone()).await;
let mut untouched =
decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly).await.expect("identity setup");
assert!(untouched.decoded_from.is_empty());
assert_eq!(read_to_end_limited(&mut untouched.body, 1024).await.expect("untouched body"), encoded_binary);
}
#[tokio::test]
async fn content_coding_header_presence_disables_manifest_magic_even_for_identity() {
let encoded = encode(MANIFEST, Encoding::Gzip).await;
let response = local_response(StatusCode::OK, &[("Content-Encoding", "identity")], encoded.clone()).await;
let mut decoded =
decode_response_to_identity(response, ContentCodingDetection::DeclaredOrKnownHlsManifestMagic)
.await
.expect("identity setup");
assert!(decoded.decoded_from.is_empty());
assert_eq!(decoded.content_coding_observation(), None);
assert!(!decoded.headers.contains_key(CONTENT_ENCODING));
assert_eq!(read_to_end_limited(&mut decoded.body, 1024).await.expect("identity body"), encoded);
}
#[tokio::test]
async fn content_coding_rejects_encoded_partial_content_but_preserves_identity_range_headers() {
let encoded = encode(MANIFEST, Encoding::Gzip).await;
let response = local_response(
StatusCode::PARTIAL_CONTENT,
&[("Content-Encoding", "gzip"), ("Content-Range", "bytes 0-9/20")],
encoded,
)
.await;
assert!(matches!(
decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly).await,
Err(ContentCodingError::EncodedPartialContent)
));
let response = local_response(
StatusCode::PARTIAL_CONTENT,
&[("Content-Encoding", "identity"), ("Content-Range", "bytes 0-2/10")],
b"abc".to_vec(),
)
.await;
let decoded = decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly)
.await
.expect("identity partial response");
assert_eq!(decoded.status, StatusCode::PARTIAL_CONTENT);
assert_eq!(decoded.headers[CONTENT_RANGE], "bytes 0-2/10");
}
#[tokio::test]
async fn content_coding_normalizes_only_representation_dependent_headers() {
let encoded = encode(MANIFEST, Encoding::Gzip).await;
let response = local_response(
StatusCode::OK,
&[
("Content-Encoding", "gzip"),
("Content-Range", "bytes 0-9/10"),
("Accept-Ranges", "bytes"),
("ETag", "origin-tag"),
("Content-MD5", "abc"),
("Digest", "sha-256=abc"),
("Content-Digest", "sha-256=:abc:"),
("Repr-Digest", "sha-256=:abc:"),
("Vary", "Accept-Encoding, Origin"),
("Vary", "*"),
("Set-Cookie", "session=secret"),
],
encoded,
)
.await;
let decoded = decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly)
.await
.expect("decoded response");
for name in [
CONTENT_ENCODING,
CONTENT_LENGTH,
CONTENT_RANGE,
ACCEPT_RANGES,
ETAG,
HeaderName::from_static("content-md5"),
HeaderName::from_static("digest"),
HeaderName::from_static("content-digest"),
HeaderName::from_static("repr-digest"),
TRANSFER_ENCODING,
] {
assert!(!decoded.headers.contains_key(&name), "stale header {name}");
}
let vary =
decoded.headers.get_all(VARY).iter().map(|value| value.to_str().expect("ASCII Vary")).collect::<Vec<_>>();
assert_eq!(vary, vec!["Origin", "*"]);
assert_eq!(decoded.headers[header::SET_COOKIE], "session=secret");
}
#[test]
fn content_coding_normalizer_removes_transfer_encoding_but_keeps_safe_metadata() {
let mut headers = HeaderMap::new();
headers.insert(TRANSFER_ENCODING, HeaderValue::from_static("chunked"));
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/vnd.apple.mpegurl"));
normalize_headers_after_content_decoding(&mut headers);
assert!(!headers.contains_key(TRANSFER_ENCODING));
assert_eq!(headers[header::CONTENT_TYPE], "application/vnd.apple.mpegurl");
}
#[test]
fn content_coding_normalizer_preserves_opaque_vary_without_panicking() {
let opaque_vary = HeaderValue::from_bytes(&[0xff]).expect("opaque header value");
let mut headers = HeaderMap::new();
headers.insert(VARY, opaque_vary.clone());
normalize_headers_after_content_decoding(&mut headers);
assert_eq!(headers[VARY], opaque_vary);
}
#[tokio::test]
async fn content_coding_surfaces_typed_errors_for_truncated_streams() {
for (encoding, coding) in [
(Encoding::Gzip, ContentCoding::Gzip),
(Encoding::Brotli, ContentCoding::Brotli),
(Encoding::Zstd, ContentCoding::Zstd),
] {
let mut encoded = encode(MANIFEST, encoding).await;
encoded.truncate(encoded.len() / 2);
let response =
local_response(StatusCode::OK, &[("Content-Encoding", encoding.header_value())], encoded).await;
let mut decoded = decode_response_to_identity(response, ContentCodingDetection::DeclaredOnly)
.await
.expect("lazy decoder setup");
let error = read_to_end_limited(&mut decoded.body, 1024).await.expect_err("truncated stream");
let ContentBodyReadError::Io(error) = error else {
panic!("expected decoder I/O error for {encoding:?}");
};
assert_eq!(content_decoding_error_from_io(&error).expect("typed decoder error").coding, coding);
}
}
#[tokio::test]
async fn content_coding_limit_and_utf8_errors_are_distinct() {
let mut exact = Cursor::new(b"abcd".to_vec());
assert_eq!(read_to_end_limited(&mut exact, 4).await.expect("exact limit"), b"abcd");
let mut oversized = Cursor::new(b"abcde".to_vec());
assert!(matches!(
read_to_end_limited(&mut oversized, 4).await,
Err(ContentBodyReadError::LimitExceeded { limit: 4 })
));
let mut invalid_utf8 = Cursor::new(vec![b'a', 0xff]);
assert!(matches!(
read_utf8_limited(&mut invalid_utf8, 10).await,
Err(ContentBodyReadError::InvalidUtf8 { valid_up_to: 1, error_len: Some(1) })
));
}
#[derive(Debug, Clone, Copy)]
enum Encoding {
Gzip,
Zlib,
RawDeflate,
Brotli,
Zstd,
}
impl Encoding {
const fn header_value(self) -> &'static str {
match self {
Self::Gzip => "gzip",
Self::Zlib | Self::RawDeflate => "deflate",
Self::Brotli => "br",
Self::Zstd => "zstd",
}
}
}
async fn encode(input: &[u8], encoding: Encoding) -> Vec<u8> {
let reader = BufReader::new(Cursor::new(input.to_vec()));
let mut result = Vec::new();
match encoding {
Encoding::Gzip => GzipEncoder::new(reader).read_to_end(&mut result).await.expect("gzip encode"),
Encoding::Zlib => ZlibEncoder::new(reader).read_to_end(&mut result).await.expect("zlib encode"),
Encoding::RawDeflate => {
DeflateEncoder::new(reader).read_to_end(&mut result).await.expect("raw deflate encode")
}
Encoding::Brotli => BrotliEncoder::new(reader).read_to_end(&mut result).await.expect("Brotli encode"),
Encoding::Zstd => ZstdEncoder::new(reader).read_to_end(&mut result).await.expect("Zstandard encode"),
};
result
}
async fn local_response(status: StatusCode, headers: &[(&str, &str)], body: Vec<u8>) -> reqwest::Response {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test origin");
let address = listener.local_addr().expect("test origin address");
let has_content_length = headers.iter().any(|(name, _)| name.eq_ignore_ascii_case("content-length"));
let owned_headers =
headers.iter().map(|(name, value)| ((*name).to_owned(), (*value).to_owned())).collect::<Vec<_>>();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accept test request");
let mut request = Vec::new();
let mut buffer = [0_u8; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let read = socket.read(&mut buffer).await.expect("read test request");
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
}
let mut response = format!("HTTP/1.1 {} Test\r\nConnection: close\r\n", status.as_u16());
if !has_content_length {
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
}
for (name, value) in owned_headers {
response.push_str(&name);
response.push_str(": ");
response.push_str(&value);
response.push_str("\r\n");
}
response.push_str("\r\n");
socket.write_all(response.as_bytes()).await.expect("write test response head");
socket.write_all(&body).await.expect("write test response body");
});
reqwest::Client::builder()
.no_proxy()
.redirect(Policy::none())
.build()
.expect("test client")
.get(format!("http://{address}/manifest.m3u8"))
.send()
.await
.expect("fetch test response")
}
}
+7
View File
@@ -1,7 +1,14 @@
use std::pin::Pin;
use tokio::io::AsyncRead;
pub(crate) mod content_coding;
pub mod epg;
pub mod ip_checker;
pub mod m3u;
pub mod request;
pub mod xtream;
/// Type-erased asynchronous reader used by the existing network download stack.
pub type DynReader = Pin<Box<dyn AsyncRead + Send>>;
pub use request::format_http_status;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -618,6 +618,7 @@ mod tests {
input_name: "provider".intern(),
channel_no: 1,
source_ordinal: 1,
input_stream_id: "813563".intern(),
}
}
@@ -23,12 +23,21 @@ For an operator-friendly introduction, see [Shared HLS Sessions](./shared-hls-se
| Identifier | Scope | Meaning |
| :--- | :--- | :--- |
| `HlsSessionKey` | Shared content | Stable tuple of `input_id`, HLS kind, and `stream_ref`. It does not include the origin URL, provider URL, username, or password. |
| `HlsSessionKey` | Shared content | Stable tuple of `input_id`, the literal HLS kind, and `stream_ref`, where `stream_ref` is the unchanged original `input_stream_id`. |
| `proxy_session_id` | Shared public URL identity | Opaque token derived from `HlsSessionKey` and the configured secret. It identifies the shared content session in canonical URLs. |
| `HlsPlaybackFamilyKey` | User/client family | Tuple of Tuliprox username and client fingerprint key. It groups playback attempts by user and client. |
| `hls_access_lease_id` | Per playback URL identity | Random lookup key for one server-side `HlsAccessLease`. It is not a shared-content identity. |
| HLS cache user session token | Per playback admission | Internal Tuliprox user-session token associated with an access lease. |
The canonical internal key is `input:<input_id>|hls|<input_stream_id>`. The input stream ID is captured from the input
playlist item before target mapping: the provider/origin stream ID as a decimal string for Xtream, or the parser-resolved
string ID for M3U. The latter can be alphanumeric or a stable URL-derived hash. `target_id`, `virtual_id`, origin URLs,
credentials, and provider mirrors do not participate in session identity. Therefore, different targets reuse one
`HlsSession` when both `input_id` and `input_stream_id` match, while their access leases remain separate.
The runtime assumes that `(input_id, input_stream_id)` uniquely identifies one input stream. A missing input stream ID is
not replaced with `virtual_id`; the Shared HLS entry fails instead of joining an ambiguously identified session.
The public canonical paths are:
```text
@@ -9,13 +9,16 @@ reference, see [HLS Cache State Machines](./hls-cache-state-machine.md).
| Identifier | Scope | Created by | Purpose |
| :--- | :--- | :--- | :--- |
| `HlsSessionKey` | Shared content | Built from `input_id`, the literal HLS kind, and `stream_ref`. | Stable internal key for one shared live HLS session. It does not include provider username, password, or origin URL. |
| `proxy_session_id` | Public shared content URL | Derived from `HlsSessionKey` and the configured secret. | Opaque URL token for the shared session. The same input and stream reference produce the same public session ID while the secret stays stable. |
| `HlsSessionKey` | Shared content | Built as `input:<input_id>\|hls\|<input_stream_id>`. | Stable internal key for one shared live HLS session. The input stream ID is captured before target mapping. |
| `proxy_session_id` | Public shared content URL | Derived from `HlsSessionKey` and the configured secret. | Opaque URL token for the shared session. The same input and original input stream ID produce the same public session ID while the secret stays stable. |
| `HlsPlaybackFamilyKey` | Playback family | Built from Tuliprox username and client fingerprint. | Groups related playback attempts by user and client. |
| `hls_access_lease_id` | Per playback URL | Random server-side lease ID. | Lets exactly one playback path use the shared session after Tuliprox validates the user and lease. |
| HLS cache user session token | Per playback admission | Generated for the Shared HLS entry request. | Connects the access lease to Tuliprox user session accounting and connection handling. |
The key design point is that `proxy_session_id` is shared, but `hls_access_lease_id` is not.
The key design point is that `proxy_session_id` is shared, but `hls_access_lease_id` is not. The `stream_ref` field used
inside `HlsSessionKey` is exactly the original `input_stream_id`: an Xtream provider/origin stream ID as a decimal string,
or the parser-resolved M3U ID, including alphanumeric IDs and stable URL-derived hashes. `target_id` and `virtual_id` stay
in the routing and access context and do not affect this shared identity.
## End-to-end request flow
@@ -50,8 +53,9 @@ sequenceDiagram
## Step 1: Entry request
The player first opens a generated Tuliprox live HLS URL. Tuliprox resolves the target, input, virtual ID, stream URL, and
user connection permission.
The player first opens a generated Tuliprox live HLS URL. Tuliprox resolves the target, input, virtual ID, original input
stream ID, stream URL, and user connection permission. The original input stream ID was captured before target mapping
and is not replaced by the target's virtual ID.
If Shared HLS is enabled for the target, Tuliprox creates a fresh `Pending` `HlsAccessLease` for this playback and returns
an HTTP `307 Temporary Redirect` to the canonical shared manifest path:
@@ -213,3 +217,8 @@ Typical outcomes are:
- `503 Service Unavailable` with `Retry-After` when origin/cache work is temporarily not ready.
Players should reopen the original generated playlist entry URL when they recover from a long pause or stale HLS path.
An upgrade from `virtual_id`-based session identity also makes old canonical URLs stale when the virtual and input stream
IDs differ. New entry requests receive the new `proxy_session_id`; old cache namespaces are left for normal garbage
collection. Persisted M3U-to-Xtream data that lost a nonnumeric input ID as `provider_id = 0` must be refreshed before the
entry can safely join a shared session.
+23 -2
View File
@@ -43,7 +43,7 @@ separate `share_live_streams.mpeg_ts` option.
| Shared between viewers | Still separate per viewer |
| :--- | :--- |
| The stable `HlsSession` for the same input and live stream reference. | The `hls_access_lease_id` in the user-facing URL. |
| The stable `HlsSession` for the same input and original input stream ID. | The `hls_access_lease_id` in the user-facing URL. |
| Origin manifest refreshes and origin segment fetches when the cache can reuse them. | Tuliprox username, client fingerprint, and access checks. |
| Cached HLS segments and HLS MAP objects. | User connection admission and stream reservation lifecycle. |
| Transient HLS resource handling when the manifest requires it. | User-visible error handling and custom video redirects. |
@@ -51,6 +51,26 @@ separate `share_live_streams.mpeg_ts` option.
This means the feature is not a bypass for user limits. It is a way to avoid duplicated upstream HLS work after Tuliprox
has accepted each viewer.
## How is a shared session identified?
Tuliprox builds the shared content identity as:
```text
input:<input_id>|hls|<input_stream_id>
```
`input_id` identifies the configured Tuliprox input. `input_stream_id` is the original stream ID captured from the input
playlist item before target mapping. For Xtream inputs, it is the provider/origin stream ID rendered as a decimal string.
For M3U inputs, it is the exact ID resolved by the parser, which can also be alphanumeric or a stable URL-derived hash.
The target ID and `virtual_id` are not part of this key. Consequently, two targets with different virtual IDs share the
same content session when they refer to the same `input_id` and `input_stream_id`. Their access leases, routing context,
and user admission remain separate. Origin credentials, direct origin URLs, and the currently selected provider mirror
also do not change the shared session identity.
The pair `(input_id, input_stream_id)` must uniquely identify one stream within the processed inputs. Target mapping must
not change the captured input stream ID.
## Requirements
Shared HLS starts only when both switches are enabled:
@@ -98,7 +118,8 @@ targets:
## Request flow in plain language
1. A player opens the normal generated Tuliprox HLS live URL.
2. Tuliprox authenticates the user and checks whether this target allows Shared HLS.
2. Tuliprox authenticates the user, resolves the unchanged input stream ID, and checks whether this target allows Shared
HLS.
3. Tuliprox creates a new access lease for that playback.
4. Tuliprox returns an HTTP `307 Temporary Redirect` to the canonical shared HLS manifest URL.
5. The player follows the redirect and requests `/hls/shared/live/.../manifest.m3u8`.
+197 -4
View File
@@ -278,6 +278,8 @@ pub trait FieldSetAccessor {
pub trait PlaylistEntry: Send + Sync {
fn get_virtual_id(&self) -> VirtualId;
/// Returns the immutable stream identifier captured from the input playlist before target mappings.
fn get_input_stream_id(&self) -> Option<Arc<str>>;
fn get_provider_id(&self) -> Option<u32>;
fn get_category_id(&self) -> Option<u32>;
fn get_provider_url(&self) -> Arc<str>;
@@ -335,6 +337,9 @@ pub struct PlaylistItemHeader {
pub xtream_cluster: XtreamCluster,
#[serde(default)]
pub item_type: PlaylistItemType,
/// Stable provider/origin ID captured before any target transformation.
#[serde(default, with = "arc_str_serde")]
pub input_stream_id: Arc<str>,
}
impl Default for PlaylistItemHeader {
@@ -361,11 +366,30 @@ impl Default for PlaylistItemHeader {
category_id: 0,
input_name: "".intern(),
source_ordinal: 0,
input_stream_id: "".intern(),
}
}
}
impl PlaylistItemHeader {
/// Captures the input playlist item ID at the input-processing boundary.
///
/// This must run before target transformations and must not be used to recover an identity
/// from a target-mutated `id`.
pub fn freeze_input_stream_id(&mut self) {
if self.input_stream_id.is_empty() && !self.id.is_empty() {
self.input_stream_id = Arc::clone(&self.id);
}
}
/// Returns the input stream ID captured at the input-processing boundary.
///
/// Legacy fallback must be resolved before a target transformation starts. Once an item is
/// represented by this header, the mutable target-facing `id` is never a valid fallback.
pub fn get_input_stream_id(&self) -> Option<Arc<str>> {
(!self.input_stream_id.is_empty()).then(|| Arc::clone(&self.input_stream_id))
}
#[inline]
pub fn gen_uuid(&mut self) {
self.uuid = generate_runtime_playlist_uuid(&self.input_name, &self.id, self.item_type, &self.url);
@@ -544,6 +568,9 @@ pub struct M3uPlaylistItem {
pub source_ordinal: u32,
#[serde(default)]
pub additional_properties: Option<StreamProperties>,
/// Stable provider/origin ID captured before any target transformation.
#[serde(default, with = "arc_str_serde")]
pub input_stream_id: Arc<str>,
}
fn write_m3u_attr(line: &mut String, name: &str, value: &str) { let _ = write!(line, " {name}=\"{value}\""); }
@@ -660,6 +687,11 @@ impl PlaylistEntry for M3uPlaylistItem {
#[inline]
fn get_virtual_id(&self) -> VirtualId { self.virtual_id }
fn get_input_stream_id(&self) -> Option<Arc<str>> {
let input_stream_id = if self.input_stream_id.is_empty() { &self.provider_id } else { &self.input_stream_id };
(!input_stream_id.is_empty()).then(|| Arc::clone(input_stream_id))
}
fn get_provider_id(&self) -> Option<u32> { get_provider_id(&self.provider_id, &self.url) }
#[inline]
fn get_category_id(&self) -> Option<u32> { None }
@@ -884,6 +916,9 @@ pub struct XtreamPlaylistItem {
pub channel_no: u32,
#[serde(default)]
pub source_ordinal: u32,
/// Stable provider/origin ID captured before any target transformation.
#[serde(default, with = "arc_str_serde")]
pub input_stream_id: Arc<str>,
}
impl XtreamPlaylistItem {
@@ -940,6 +975,13 @@ impl XtreamPlaylistItem {
impl PlaylistEntry for XtreamPlaylistItem {
#[inline]
fn get_virtual_id(&self) -> VirtualId { self.virtual_id }
fn get_input_stream_id(&self) -> Option<Arc<str>> {
if self.input_stream_id.is_empty() {
(self.provider_id > 0).then(|| self.provider_id.to_string().intern())
} else {
Some(Arc::clone(&self.input_stream_id))
}
}
#[inline]
fn get_provider_id(&self) -> Option<u32> { Some(self.provider_id) }
#[inline]
@@ -1166,7 +1208,11 @@ impl PlaylistItem {
impl From<&PlaylistItem> for XtreamPlaylistItem {
fn from(item: &PlaylistItem) -> Self {
let header = &item.header;
let provider_id = get_provider_id(&header.id, &header.url).unwrap_or_default();
let input_stream_id = Arc::clone(&header.input_stream_id);
let missing_live_input_identity =
input_stream_id.is_empty() && (header.item_type.is_live() || header.item_type == PlaylistItemType::Catchup);
let provider_id =
if missing_live_input_identity { 0 } else { get_provider_id(&header.id, &header.url).unwrap_or_default() };
let additional_properties = PlaylistItem::get_additional_properties(header);
XtreamPlaylistItem {
@@ -1192,6 +1238,7 @@ impl From<&PlaylistItem> for XtreamPlaylistItem {
input_name: Arc::clone(&header.input_name),
channel_no: header.chno,
source_ordinal: header.source_ordinal,
input_stream_id,
}
}
}
@@ -1199,9 +1246,12 @@ impl From<&PlaylistItem> for XtreamPlaylistItem {
impl From<&PlaylistItem> for M3uPlaylistItem {
fn from(item: &PlaylistItem) -> Self {
let header = &item.header;
let input_stream_id = Arc::clone(&header.input_stream_id);
let missing_live_input_identity =
input_stream_id.is_empty() && (header.item_type.is_live() || header.item_type == PlaylistItemType::Catchup);
M3uPlaylistItem {
virtual_id: header.virtual_id,
provider_id: Arc::clone(&header.id),
provider_id: if missing_live_input_identity { "".intern() } else { Arc::clone(&header.id) },
name: if header.item_type == PlaylistItemType::Series {
Arc::clone(&header.title)
} else {
@@ -1226,6 +1276,7 @@ impl From<&PlaylistItem> for M3uPlaylistItem {
t_catchup_mode: None,
source_ordinal: header.source_ordinal,
additional_properties: header.additional_properties.clone(),
input_stream_id,
}
}
}
@@ -1266,10 +1317,15 @@ impl From<&PlaylistItem> for CommonPlaylistItem {
impl From<&XtreamPlaylistItem> for PlaylistItem {
fn from(item: &XtreamPlaylistItem) -> Self {
let input_stream_id = item.get_input_stream_id();
let header = PlaylistItemHeader {
uuid: item.get_uuid(),
virtual_id: item.virtual_id,
id: item.provider_id.to_string().intern(),
id: if item.provider_id == 0 && input_stream_id.is_none() {
"".intern()
} else {
item.provider_id.to_string().intern()
},
name: item.name.clone(),
title: item.title.clone(),
logo: item.logo.clone(),
@@ -1288,6 +1344,7 @@ impl From<&XtreamPlaylistItem> for PlaylistItem {
time_shift: "".intern(),
additional_properties: item.additional_properties.clone(),
source_ordinal: item.source_ordinal,
input_stream_id: input_stream_id.unwrap_or_else(|| "".intern()),
};
PlaylistItem { header }
@@ -1318,6 +1375,7 @@ impl From<&M3uPlaylistItem> for PlaylistItem {
time_shift: item.time_shift.clone(),
additional_properties: item.additional_properties.clone(),
source_ordinal: item.source_ordinal,
input_stream_id: item.get_input_stream_id().unwrap_or_else(|| "".intern()),
};
PlaylistItem { header }
@@ -1328,6 +1386,9 @@ impl PlaylistEntry for PlaylistItem {
#[inline]
fn get_virtual_id(&self) -> VirtualId { self.header.virtual_id }
#[inline]
fn get_input_stream_id(&self) -> Option<Arc<str>> { self.header.get_input_stream_id() }
fn get_provider_id(&self) -> Option<u32> {
let header = &self.header;
get_provider_id(&header.id, &header.url)
@@ -1389,6 +1450,7 @@ impl PlaylistGroup {
#[inline]
pub fn on_load(&mut self) {
for pl in &mut self.channels {
pl.header.freeze_input_stream_id();
pl.header.gen_uuid();
pl.header.category_id = self.id;
}
@@ -1554,7 +1616,7 @@ mod tests {
#[test]
fn xtream_playlist_item_conversion_falls_back_to_numeric_id_in_url() {
let item = PlaylistItem {
let mut item = PlaylistItem {
header: PlaylistItemHeader {
id: "channel-alpha".intern(),
url: "http://provider.example/live/user/pass/12345.ts".intern(),
@@ -1564,16 +1626,142 @@ mod tests {
..PlaylistItemHeader::default()
},
};
item.header.freeze_input_stream_id();
let xtream_item = XtreamPlaylistItem::from(&item);
assert_eq!(xtream_item.provider_id, 12345);
}
#[test]
fn xtream_playlist_item_preserves_numeric_input_stream_id_as_string() {
let mut item = PlaylistItem {
header: PlaylistItemHeader {
id: "80510".intern(),
virtual_id: 1001,
url: "http://provider.example/live/user/pass/80510.ts".intern(),
input_name: "input".intern(),
item_type: PlaylistItemType::Live,
xtream_cluster: XtreamCluster::Live,
..PlaylistItemHeader::default()
},
};
item.header.freeze_input_stream_id();
let xtream_item = XtreamPlaylistItem::from(&item);
assert_eq!(xtream_item.provider_id, 80510);
assert_eq!(xtream_item.input_stream_id.as_ref(), "80510");
assert_eq!(xtream_item.get_input_stream_id().as_deref(), Some("80510"));
}
#[test]
fn alphanumeric_m3u_input_stream_id_survives_xtream_materialization() {
let mut source = PlaylistItem {
header: PlaylistItemHeader {
id: "channel-alpha".intern(),
url: "http://provider.example/live/user/pass/12345.ts".intern(),
input_name: "input".intern(),
item_type: PlaylistItemType::Live,
xtream_cluster: XtreamCluster::Live,
..PlaylistItemHeader::default()
},
};
source.header.freeze_input_stream_id();
let m3u_item = M3uPlaylistItem::from(&source);
let common_item = PlaylistItem::from(&m3u_item);
let xtream_item = XtreamPlaylistItem::from(&common_item);
assert_eq!(m3u_item.input_stream_id.as_ref(), "channel-alpha");
assert_eq!(xtream_item.provider_id, 12345);
assert_eq!(xtream_item.input_stream_id.as_ref(), "channel-alpha");
}
#[test]
fn url_hash_input_stream_id_is_preserved_verbatim() {
let mut source = PlaylistItem {
header: PlaylistItemHeader {
id: "d34db33f".intern(),
url: "http://provider.example/live/channel.m3u8".intern(),
..PlaylistItemHeader::default()
},
};
source.header.freeze_input_stream_id();
let m3u_item = M3uPlaylistItem::from(&source);
assert_eq!(m3u_item.input_stream_id.as_ref(), "d34db33f");
}
#[test]
fn target_field_mapping_cannot_change_frozen_input_stream_id() {
let mut header = PlaylistItemHeader { id: "origin-alpha".intern(), ..PlaylistItemHeader::default() };
header.freeze_input_stream_id();
assert!(header.set_field("id", "target-id"));
assert_eq!(header.id.as_ref(), "target-id");
assert_eq!(header.input_stream_id.as_ref(), "origin-alpha");
assert!(!header.set_field("input_stream_id", "unexpected"));
assert_eq!(header.input_stream_id.as_ref(), "origin-alpha");
}
#[test]
fn legacy_playlist_items_fall_back_to_provider_id_without_virtual_id() {
let mut source = PlaylistItem {
header: PlaylistItemHeader {
id: "legacy-alpha".intern(),
virtual_id: 7001,
url: "http://provider.example/live/user/pass/80510.ts".intern(),
..PlaylistItemHeader::default()
},
};
source.header.freeze_input_stream_id();
let mut m3u_item = M3uPlaylistItem::from(&source);
m3u_item.input_stream_id = "".intern();
let mut xtream_item = XtreamPlaylistItem::from(&source);
xtream_item.input_stream_id = "".intern();
assert_eq!(m3u_item.get_input_stream_id().as_deref(), Some("legacy-alpha"));
assert_eq!(xtream_item.get_input_stream_id().as_deref(), Some("80510"));
xtream_item.provider_id = 0;
xtream_item.url = "http://provider.example/live/channel.ts".intern();
assert_eq!(xtream_item.get_input_stream_id(), None);
let mut legacy_common_item = PlaylistItem::from(&xtream_item);
assert!(legacy_common_item.header.id.is_empty());
assert_eq!(legacy_common_item.get_input_stream_id(), None);
legacy_common_item.header.id = "target-mapped-id".intern();
let rematerialized_m3u_item = M3uPlaylistItem::from(&legacy_common_item);
let rematerialized_xtream_item = XtreamPlaylistItem::from(&legacy_common_item);
assert_eq!(legacy_common_item.header.id.as_ref(), "target-mapped-id");
assert_eq!(legacy_common_item.get_input_stream_id(), None);
assert!(rematerialized_m3u_item.provider_id.is_empty());
assert_eq!(rematerialized_m3u_item.get_input_stream_id(), None);
assert_eq!(rematerialized_xtream_item.provider_id, 0);
assert_eq!(rematerialized_xtream_item.get_input_stream_id(), None);
xtream_item.input_stream_id = "explicit-origin-alpha".intern();
assert_eq!(xtream_item.get_input_stream_id().as_deref(), Some("explicit-origin-alpha"));
let identified_common_item = PlaylistItem::from(&xtream_item);
assert_eq!(identified_common_item.header.input_stream_id.as_ref(), "explicit-origin-alpha");
assert_eq!(identified_common_item.get_input_stream_id().as_deref(), Some("explicit-origin-alpha"));
let identified_m3u_item = M3uPlaylistItem::from(&identified_common_item);
let identified_xtream_item = XtreamPlaylistItem::from(&identified_common_item);
assert_eq!(identified_m3u_item.input_stream_id.as_ref(), "explicit-origin-alpha");
assert_eq!(identified_m3u_item.get_input_stream_id().as_deref(), Some("explicit-origin-alpha"));
assert_eq!(identified_xtream_item.provider_id, 0);
assert_eq!(identified_xtream_item.input_stream_id.as_ref(), "explicit-origin-alpha");
assert_eq!(identified_xtream_item.get_input_stream_id().as_deref(), Some("explicit-origin-alpha"));
}
#[test]
fn m3u_to_m3u_emits_tvg_id_from_epg_channel_id() {
let item = M3uPlaylistItem {
virtual_id: 0,
provider_id: "prov1".intern(),
input_stream_id: "prov1".intern(),
name: "Test Channel".intern(),
chno: 0,
logo: "".intern(),
@@ -1607,6 +1795,7 @@ mod tests {
let item = M3uPlaylistItem {
virtual_id: 0,
provider_id: "prov1".intern(),
input_stream_id: "prov1".intern(),
name: "Test Channel".intern(),
chno: 0,
logo: "".intern(),
@@ -1638,6 +1827,7 @@ mod tests {
let item = M3uPlaylistItem {
virtual_id: 0,
provider_id: "prov1".intern(),
input_stream_id: "prov1".intern(),
name: "Test Channel".intern(),
chno: 42,
logo: "".intern(),
@@ -1669,6 +1859,7 @@ mod tests {
let item = M3uPlaylistItem {
virtual_id: 0,
provider_id: "prov1".intern(),
input_stream_id: "prov1".intern(),
name: "Test Channel".intern(),
chno: 0,
logo: "".intern(),
@@ -1700,6 +1891,7 @@ mod tests {
let item = M3uPlaylistItem {
virtual_id: 0,
provider_id: "prov1".intern(),
input_stream_id: "prov1".intern(),
name: "Test Channel".intern(),
chno: 0,
logo: "".intern(),
@@ -1747,6 +1939,7 @@ mod tests {
let item = M3uPlaylistItem {
virtual_id: 0,
provider_id: "prov1".intern(),
input_stream_id: "prov1".intern(),
name: "Test Channel".intern(),
chno: 0,
logo: "".intern(),
+4
View File
@@ -383,6 +383,7 @@ mod tests {
input_name: "demo".intern(),
channel_no: 0,
source_ordinal: 0,
input_stream_id: "1".intern(),
};
let stream_channel = create_stream_channel_with_type(1, &playlist_item, PlaylistItemType::Video);
@@ -412,6 +413,7 @@ mod tests {
input_name: "provider-input".intern(),
channel_no: 0,
source_ordinal: 0,
input_stream_id: "1".intern(),
};
let stream_channel = playlist_item.to_stream_channel(1);
@@ -444,6 +446,7 @@ mod tests {
t_catchup_mode: None,
source_ordinal: 0,
additional_properties: None,
input_stream_id: "42".intern(),
};
let channel = pli.to_stream_channel(7).with_epg_reference_ts(Some(1_700_000_000));
@@ -479,6 +482,7 @@ mod tests {
t_catchup_mode: None,
source_ordinal: 0,
additional_properties: None,
input_stream_id: "42".intern(),
};
// to_stream_channel() defaults to None, and with_epg_reference_ts(None)
+1
View File
@@ -1051,6 +1051,7 @@ mod tests {
input_name: "input".into(),
channel_no: 0,
source_ordinal: 0,
input_stream_id: "1001".into(),
}
}
-4
View File
@@ -15,9 +15,6 @@ pub const DEFAULT_HOME_ENV_VAR: &str = "TULIPROX_HOME";
pub const DEFAULT_WEB_ROOT_ENV_VAR: &str = "TULIPROX_WEB_ROOT";
pub const DNS_RESOLVED_FILE: &str = "provider_dns_resolved.json";
pub const ENCODING_GZIP: &str = "gzip";
pub const ENCODING_DEFLATE: &str = "deflate";
pub const FILENAME_TRIM_PATTERNS: &[char] = &['.', '-', '_'];
const SUPPORTED_RESPONSE_HEADERS: &[&str] = &[
@@ -27,7 +24,6 @@ const SUPPORTED_RESPONSE_HEADERS: &[&str] = &[
"content-length",
"content-range",
"vary",
"transfer-encoding",
//"connection",
"access-control-allow-origin",
"access-control-allow-credentials",