mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-25 16:22:23 +01:00
* 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.
1746 lines
71 KiB
Rust
1746 lines
71 KiB
Rust
use super::{
|
|
build_hls_origin_resource_headers_with_client_range, finish_hls_origin_account_io, hls_client_body_send_deadline,
|
|
refresh_hls_client_body_send_deadline,
|
|
resource_fetch::{log_hls_resource_body_failure, HlsResourceFetchLogContext},
|
|
run_hls_origin_resource_retry_loop_with_attempt_prepare, safe_proxy_session_id, CacheAccessState,
|
|
HlsAccessLeaseChannelUnavailableReason, HlsAccessLeaseId, HlsMediaActivityMarker, HlsOriginAccountIoLeaseGuard,
|
|
HlsOriginByteRangeExpectation, HlsOriginIoContext, HlsOriginResourceBodyDeadline, HlsOriginResourceClients,
|
|
HlsOriginResourceFetchError, HlsOriginResourceFetchTarget, HlsProxyManager, HlsRepairRenderedObjectId,
|
|
HlsResourceFetchAttempt, HlsResourceFetchKind, HlsResourceFetchSource, HlsSegmentCache, HlsSegmentFailureObject,
|
|
HlsSegmentFailureTransition, HlsSegmentRepairManager, HlsSegmentRepairObjectContext, HlsSegmentRepairSource,
|
|
HlsSessionHandle, ProxySessionId, SegmentFetchPolicy, TransientObjectCacheKey, TransientObjectFetchDecision,
|
|
TransientPassthroughState, TransientResourceFile, TransientResourceKind, TransientResourceRef,
|
|
};
|
|
use crate::{
|
|
api::api_utils::{mark_response_as_uncompressed, try_unwrap_body},
|
|
utils::content_coding::DecodedHttpResponse,
|
|
};
|
|
use axum::{
|
|
body::Body,
|
|
http::{header, HeaderMap, HeaderValue, StatusCode},
|
|
response::IntoResponse,
|
|
};
|
|
use futures::{future::BoxFuture, FutureExt, StreamExt};
|
|
use log::{debug, warn};
|
|
use std::{io, sync::Arc};
|
|
use tokio::{sync::Notify, time::sleep};
|
|
use tokio_util::io::ReaderStream;
|
|
|
|
pub enum HlsTransientObjectFetchFailure {
|
|
Retryable,
|
|
Permanent { status: Option<StatusCode> },
|
|
}
|
|
|
|
pub fn hls_transient_object_fetch_failure(error: &HlsOriginResourceFetchError) -> HlsTransientObjectFetchFailure {
|
|
if let Some(status) = error.permanent_status() {
|
|
return HlsTransientObjectFetchFailure::Permanent { status: Some(status) };
|
|
}
|
|
if !error.retryable_failure() {
|
|
return HlsTransientObjectFetchFailure::Permanent { status: None };
|
|
}
|
|
HlsTransientObjectFetchFailure::Retryable
|
|
}
|
|
|
|
pub fn hls_transient_resource_fetch_kind(resource_kind: TransientResourceKind) -> HlsResourceFetchKind {
|
|
match resource_kind {
|
|
TransientResourceKind::Key => HlsResourceFetchKind::Key,
|
|
TransientResourceKind::Map => HlsResourceFetchKind::Map,
|
|
TransientResourceKind::Segment => HlsResourceFetchKind::Segment,
|
|
TransientResourceKind::Part => HlsResourceFetchKind::Part,
|
|
TransientResourceKind::Other => HlsResourceFetchKind::Other,
|
|
}
|
|
}
|
|
|
|
fn build_hls_transient_resource_fetch_target(
|
|
resolved_origin_uri: &str,
|
|
origin_headers: &HeaderMap,
|
|
origin_provider_session_headers: &HeaderMap,
|
|
mode: HlsTransientOriginFetchMode,
|
|
resource_id: &str,
|
|
resource_kind: TransientResourceKind,
|
|
) -> Result<HlsOriginResourceFetchTarget, HlsOriginResourceFetchError> {
|
|
let (range_header, byte_range_expectation) = match mode {
|
|
HlsTransientOriginFetchMode::CacheFullObject => (None, HlsOriginByteRangeExpectation::FullObject),
|
|
HlsTransientOriginFetchMode::DirectPassthrough { client_range } => {
|
|
(client_range, HlsOriginByteRangeExpectation::AnySuccess)
|
|
}
|
|
};
|
|
Ok(HlsOriginResourceFetchTarget {
|
|
kind: hls_transient_resource_fetch_kind(resource_kind),
|
|
source: HlsResourceFetchSource::Transient,
|
|
object_id: resource_id.to_string(),
|
|
origin_url: resolved_origin_uri.to_string(),
|
|
headers: build_hls_origin_resource_headers_with_client_range(
|
|
origin_headers,
|
|
origin_provider_session_headers,
|
|
range_header,
|
|
)?,
|
|
byte_range_expectation,
|
|
})
|
|
}
|
|
|
|
/// Keeps full-object cache fills separate from decoded client-range passthrough.
|
|
enum HlsTransientOriginFetchMode {
|
|
CacheFullObject,
|
|
DirectPassthrough { client_range: Option<HeaderValue> },
|
|
}
|
|
|
|
pub struct HlsTransientOriginFetchRequest {
|
|
pub resolved_origin_uri: String,
|
|
pub origin_headers: HeaderMap,
|
|
pub origin_provider_session_headers: HeaderMap,
|
|
pub range_header: Option<HeaderValue>,
|
|
pub resource_file: TransientResourceFile,
|
|
pub resource_kind: TransientResourceKind,
|
|
pub clients: HlsOriginResourceClients,
|
|
pub policy: SegmentFetchPolicy,
|
|
pub session_log_id: String,
|
|
}
|
|
|
|
/// A decoded direct-origin response together with the attempt and guards that own its origin work.
|
|
pub struct HlsTransientDecodedOriginResponse<G> {
|
|
pub decoded: DecodedHttpResponse,
|
|
pub body_deadline: HlsOriginResourceBodyDeadline,
|
|
pub attempt: HlsResourceFetchAttempt,
|
|
pub guard: G,
|
|
}
|
|
|
|
pub async fn fetch_hls_transient_origin_response_with_attempt_prepare<G, P>(
|
|
request: HlsTransientOriginFetchRequest,
|
|
prepare_attempt: P,
|
|
) -> Result<HlsTransientDecodedOriginResponse<G>, HlsOriginResourceFetchError>
|
|
where
|
|
G: Send + 'static,
|
|
P: FnMut(HlsResourceFetchAttempt) -> BoxFuture<'static, Result<G, HlsOriginResourceFetchError>>,
|
|
{
|
|
let target = build_hls_transient_resource_fetch_target(
|
|
&request.resolved_origin_uri,
|
|
&request.origin_headers,
|
|
&request.origin_provider_session_headers,
|
|
HlsTransientOriginFetchMode::DirectPassthrough { client_range: request.range_header },
|
|
request.resource_file.resource_id.0.as_str(),
|
|
request.resource_kind,
|
|
)?;
|
|
run_hls_origin_resource_retry_loop_with_attempt_prepare(
|
|
target,
|
|
request.clients,
|
|
&request.policy,
|
|
&request.session_log_id,
|
|
prepare_attempt,
|
|
|guard| async move { drop(guard) }.boxed(),
|
|
|decoded, attempt, body_deadline, guard| {
|
|
async move { Ok(HlsTransientDecodedOriginResponse { decoded, body_deadline, attempt, guard }) }.boxed()
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub enum HlsTransientObjectCacheAction {
|
|
ServeReady,
|
|
FetchAndCache(TransientObjectCacheKey),
|
|
WaitForFetch(Arc<Notify>),
|
|
PassthroughNoCache,
|
|
}
|
|
|
|
pub struct HlsTransientObjectCacheResolution {
|
|
pub resource: TransientResourceRef,
|
|
pub origin_headers: HeaderMap,
|
|
pub origin_provider_session_headers: HeaderMap,
|
|
pub action: HlsTransientObjectCacheAction,
|
|
}
|
|
|
|
pub async fn resolve_hls_transient_object_cache_action(
|
|
session: &HlsSessionHandle,
|
|
proxy_session_id: &ProxySessionId,
|
|
resource_file: &TransientResourceFile,
|
|
range_header: Option<&HeaderValue>,
|
|
now_ms: u64,
|
|
cache_duration_ms: u64,
|
|
) -> Result<HlsTransientObjectCacheResolution, StatusCode> {
|
|
// `is_gc_marked_for_removal` is `&self` and is the dominant early-exit on a
|
|
// busy session (GC sweeps mark sessions on a timer). Resolve it under a read
|
|
// lock so we don't pay for the exclusive write-lock acquisition when the
|
|
// session is already doomed.
|
|
if session.read().await.is_gc_marked_for_removal() {
|
|
return Err(StatusCode::NOT_FOUND);
|
|
}
|
|
let mut session = session.write().await;
|
|
let Some(resource) = session.transient.get_valid_resource(&resource_file.resource_id, now_ms) else {
|
|
return Err(StatusCode::NOT_FOUND);
|
|
};
|
|
if resource.file_ext_hint.as_deref().is_some_and(|extension| extension != resource_file.extension) {
|
|
return Err(StatusCode::NOT_FOUND);
|
|
}
|
|
let action = transient_object_cache_action(
|
|
&mut session,
|
|
proxy_session_id,
|
|
&resource,
|
|
resource_file,
|
|
range_header,
|
|
now_ms,
|
|
cache_duration_ms,
|
|
);
|
|
Ok(HlsTransientObjectCacheResolution {
|
|
resource,
|
|
origin_headers: session.origin_request_headers.clone(),
|
|
origin_provider_session_headers: session.origin_provider_session_headers.clone(),
|
|
action,
|
|
})
|
|
}
|
|
|
|
fn transient_object_cache_action(
|
|
session: &mut super::HlsSession,
|
|
proxy_session_id: &ProxySessionId,
|
|
resource: &TransientResourceRef,
|
|
resource_file: &TransientResourceFile,
|
|
range_header: Option<&HeaderValue>,
|
|
now_ms: u64,
|
|
cache_duration_ms: u64,
|
|
) -> HlsTransientObjectCacheAction {
|
|
if matches!(resource.kind, TransientResourceKind::Key) {
|
|
return HlsTransientObjectCacheAction::PassthroughNoCache;
|
|
}
|
|
let cache_key = TransientPassthroughState::transient_object_key(
|
|
proxy_session_id,
|
|
&resource.id,
|
|
resource_file.extension.clone(),
|
|
);
|
|
if session.transient.ready_object(&cache_key, now_ms).is_some() {
|
|
return HlsTransientObjectCacheAction::ServeReady;
|
|
}
|
|
if !is_hls_transient_full_object_cacheable_request(range_header) {
|
|
return HlsTransientObjectCacheAction::PassthroughNoCache;
|
|
}
|
|
session
|
|
.transient
|
|
.begin_object_fetch(proxy_session_id, resource, &resource_file.extension, now_ms, cache_duration_ms)
|
|
.into()
|
|
}
|
|
|
|
impl From<TransientObjectFetchDecision> for HlsTransientObjectCacheAction {
|
|
fn from(decision: TransientObjectFetchDecision) -> Self {
|
|
match decision {
|
|
TransientObjectFetchDecision::Ready => Self::ServeReady,
|
|
TransientObjectFetchDecision::Fetch(cache_key) => Self::FetchAndCache(cache_key),
|
|
TransientObjectFetchDecision::Wait(notifier) => Self::WaitForFetch(notifier),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn is_hls_transient_full_object_cacheable_request(range_header: Option<&HeaderValue>) -> bool {
|
|
let Some(range_header) = range_header else {
|
|
return true;
|
|
};
|
|
range_header.to_str().is_ok_and(|range| range.trim() == "bytes=0-")
|
|
}
|
|
|
|
pub struct HlsTransientObjectFetchFinalizer {
|
|
session: HlsSessionHandle,
|
|
cache_key: TransientObjectCacheKey,
|
|
completed: bool,
|
|
retry_after_ms: u64,
|
|
}
|
|
|
|
impl HlsTransientObjectFetchFinalizer {
|
|
pub fn new(session: HlsSessionHandle, cache_key: TransientObjectCacheKey, retry_after_ms: u64) -> Self {
|
|
Self { session, cache_key, completed: false, retry_after_ms }
|
|
}
|
|
|
|
pub fn complete(&mut self) { self.completed = true; }
|
|
}
|
|
|
|
impl Drop for HlsTransientObjectFetchFinalizer {
|
|
fn drop(&mut self) {
|
|
if self.completed {
|
|
return;
|
|
}
|
|
let session = Arc::clone(&self.session);
|
|
let cache_key = self.cache_key.clone();
|
|
let retry_after_ms = self.retry_after_ms;
|
|
tokio::spawn(async move {
|
|
session.write().await.transient.mark_object_failed_retryable(
|
|
&cache_key,
|
|
current_time_millis(),
|
|
retry_after_ms,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct HlsTransientCacheCommitContext {
|
|
pub segment_cache: Arc<HlsSegmentCache>,
|
|
pub segment_repair: Arc<HlsSegmentRepairManager>,
|
|
pub session: HlsSessionHandle,
|
|
pub access_lease_id: HlsAccessLeaseId,
|
|
pub resource: TransientResourceRef,
|
|
pub resource_file: TransientResourceFile,
|
|
pub cache_key: TransientObjectCacheKey,
|
|
pub cache_duration_ms: u64,
|
|
}
|
|
|
|
pub struct HlsTransientOriginCacheFetchRequest {
|
|
pub fetch: HlsTransientOriginFetchRequest,
|
|
pub commit: HlsTransientCacheCommitContext,
|
|
}
|
|
|
|
pub async fn fetch_and_commit_hls_transient_origin_response_with_attempt_prepare<G, P>(
|
|
request: HlsTransientOriginCacheFetchRequest,
|
|
prepare_attempt: P,
|
|
) -> Result<(), HlsOriginResourceFetchError>
|
|
where
|
|
G: Send + 'static,
|
|
P: FnMut(HlsResourceFetchAttempt) -> BoxFuture<'static, Result<G, HlsOriginResourceFetchError>>,
|
|
{
|
|
let target = build_hls_transient_resource_fetch_target(
|
|
&request.fetch.resolved_origin_uri,
|
|
&request.fetch.origin_headers,
|
|
&request.fetch.origin_provider_session_headers,
|
|
HlsTransientOriginFetchMode::CacheFullObject,
|
|
request.fetch.resource_file.resource_id.0.as_str(),
|
|
request.fetch.resource_kind,
|
|
)?;
|
|
let commit = request.commit;
|
|
run_hls_origin_resource_retry_loop_with_attempt_prepare(
|
|
target,
|
|
request.fetch.clients,
|
|
&request.fetch.policy,
|
|
&request.fetch.session_log_id,
|
|
prepare_attempt,
|
|
|guard| async move { drop(guard) }.boxed(),
|
|
move |decoded, _attempt, body_deadline, guard| {
|
|
let commit = commit.clone();
|
|
async move {
|
|
let result = commit_hls_transient_origin_response_attempt(commit, decoded, body_deadline).await;
|
|
drop(guard);
|
|
result
|
|
}
|
|
.boxed()
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn commit_hls_transient_origin_response_attempt(
|
|
context: HlsTransientCacheCommitContext,
|
|
decoded: DecodedHttpResponse,
|
|
body_deadline: HlsOriginResourceBodyDeadline,
|
|
) -> Result<(), HlsOriginResourceFetchError> {
|
|
let content_type = decoded
|
|
.headers
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::to_string)
|
|
.or_else(|| context.resource.content_type_hint.clone())
|
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
|
let repair_context = HlsSegmentRepairObjectContext {
|
|
source: HlsSegmentRepairSource::Transient,
|
|
proxy_session_id: context.session.read().await.proxy_session_id.clone(),
|
|
hls_access_lease_id: Some(context.access_lease_id.clone()),
|
|
rendered_object_id: HlsRepairRenderedObjectId::Transient {
|
|
resource_id: context.resource_file.resource_id.0.clone(),
|
|
},
|
|
resource_id: context.resource_file.resource_id.0.clone(),
|
|
file_ext: context.resource_file.extension.clone(),
|
|
origin_fetch_uri_for_diagnostics: context.resource.resolved_origin_uri.clone(),
|
|
media_sequence: None,
|
|
discontinuity_sequence: None,
|
|
complete_object: true,
|
|
encrypted: context.resource.kind == TransientResourceKind::Key,
|
|
custom_response: false,
|
|
};
|
|
let commit = Box::pin(context.segment_repair.commit_origin_response(
|
|
&context.segment_cache,
|
|
&context.cache_key,
|
|
decoded.body,
|
|
body_deadline.deadline(),
|
|
repair_context,
|
|
))
|
|
.await;
|
|
let ready_at_ms = current_time_millis();
|
|
let metadata = match commit {
|
|
Ok(metadata) => metadata,
|
|
Err(err) => return Err(HlsOriginResourceFetchError::cache_body(&err)),
|
|
};
|
|
let expires_at_ms = ready_at_ms.saturating_add(context.cache_duration_ms).max(context.resource.expires_at_ms);
|
|
context.session.write().await.transient.mark_object_ready(
|
|
&context.cache_key,
|
|
content_type,
|
|
metadata.size,
|
|
ready_at_ms,
|
|
expires_at_ms,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Context retained until a direct transient response body reaches one terminal outcome.
|
|
pub struct HlsTransientDirectResponseContext {
|
|
pub hls_proxy: Arc<HlsProxyManager>,
|
|
pub session: HlsSessionHandle,
|
|
pub resource: TransientResourceRef,
|
|
pub policy: SegmentFetchPolicy,
|
|
pub media_activity_marker: Option<HlsMediaActivityMarker>,
|
|
pub now_ms: u64,
|
|
pub proxy_session_id: ProxySessionId,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum HlsTransientDirectStreamOutcome {
|
|
CleanEof,
|
|
OriginBodyFailure,
|
|
ClientAborted,
|
|
}
|
|
|
|
// Sole owner of the direct-body outcome: EOF is success, origin read failures
|
|
// count as segment failures, and downstream cancellation remains neutral.
|
|
struct HlsTransientDirectResponseFinalizer {
|
|
context: Option<HlsTransientDirectResponseLifecycleContext>,
|
|
}
|
|
|
|
struct HlsTransientDirectResponseLifecycleContext {
|
|
hls_proxy: Arc<HlsProxyManager>,
|
|
session: HlsSessionHandle,
|
|
resource: TransientResourceRef,
|
|
policy: SegmentFetchPolicy,
|
|
proxy_session_id: ProxySessionId,
|
|
}
|
|
|
|
impl HlsTransientDirectResponseFinalizer {
|
|
fn new(context: HlsTransientDirectResponseLifecycleContext) -> Self {
|
|
let context = (context.resource.kind == TransientResourceKind::Segment).then_some(context);
|
|
Self { context }
|
|
}
|
|
|
|
async fn finish(&mut self, outcome: HlsTransientDirectStreamOutcome) {
|
|
let Some(completion) = self.begin_finish(outcome) else {
|
|
return;
|
|
};
|
|
if let Err(error) = completion.await {
|
|
warn!(
|
|
"HLS transient direct lifecycle task failed: cancelled={} panic={}",
|
|
error.is_cancelled(),
|
|
error.is_panic()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn begin_finish(&mut self, outcome: HlsTransientDirectStreamOutcome) -> Option<tokio::task::JoinHandle<()>> {
|
|
let context = self.context.take()?;
|
|
if matches!(outcome, HlsTransientDirectStreamOutcome::ClientAborted) {
|
|
return None;
|
|
}
|
|
// Once EOF or an origin-body failure has been observed, its state transition
|
|
// must survive cancellation of the downstream body poll.
|
|
Some(tokio::spawn(async move {
|
|
match outcome {
|
|
HlsTransientDirectStreamOutcome::CleanEof => {
|
|
record_successful_transient_segment_fetch(&context.session, &context.resource).await;
|
|
}
|
|
HlsTransientDirectStreamOutcome::OriginBodyFailure => {
|
|
let failed_at_ms = current_time_millis();
|
|
if let Some(reason) = record_temporary_transient_segment_fetch_failure(
|
|
&context.session,
|
|
&context.resource,
|
|
&context.policy,
|
|
failed_at_ms,
|
|
)
|
|
.await
|
|
{
|
|
context
|
|
.hls_proxy
|
|
.mark_access_leases_channel_unavailable_for_session(
|
|
&context.proxy_session_id,
|
|
failed_at_ms,
|
|
reason,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
HlsTransientDirectStreamOutcome::ClientAborted => {}
|
|
}
|
|
}))
|
|
}
|
|
|
|
fn finish_client_aborted(&mut self) { self.context.take(); }
|
|
}
|
|
|
|
impl Drop for HlsTransientDirectResponseFinalizer {
|
|
fn drop(&mut self) {
|
|
// A body dropped before EOF is a downstream abort. It must release the
|
|
// retained guards without changing provider/segment failure state.
|
|
self.finish_client_aborted();
|
|
}
|
|
}
|
|
|
|
pub fn hls_transient_origin_response(
|
|
response: HlsTransientDecodedOriginResponse<Option<HlsTransientOriginIoGuard>>,
|
|
context: HlsTransientDirectResponseContext,
|
|
) -> axum::response::Response {
|
|
let mut builder = axum::response::Response::builder().status(response.decoded.status);
|
|
for header_name in [
|
|
header::CONTENT_TYPE,
|
|
header::CONTENT_LENGTH,
|
|
header::CONTENT_RANGE,
|
|
header::ACCEPT_RANGES,
|
|
header::CACHE_CONTROL,
|
|
header::ETAG,
|
|
header::LAST_MODIFIED,
|
|
] {
|
|
if let Some(value) = response.decoded.headers.get(&header_name) {
|
|
builder = builder.header(header_name, value.clone());
|
|
}
|
|
}
|
|
|
|
let mut response = try_unwrap_body!(builder.body(hls_transient_direct_body(response, context)));
|
|
mark_response_as_uncompressed(&mut response);
|
|
response
|
|
}
|
|
|
|
fn hls_transient_direct_body(
|
|
response: HlsTransientDecodedOriginResponse<Option<HlsTransientOriginIoGuard>>,
|
|
context: HlsTransientDirectResponseContext,
|
|
) -> Body {
|
|
let HlsTransientDecodedOriginResponse { decoded, body_deadline, attempt, guard: origin_io_guard } = response;
|
|
let HlsTransientDirectResponseContext {
|
|
hls_proxy,
|
|
session,
|
|
resource,
|
|
policy,
|
|
media_activity_marker,
|
|
now_ms,
|
|
proxy_session_id,
|
|
} = context;
|
|
|
|
let guard = HlsTransientReadGuard::new(Arc::clone(&resource.access), now_ms);
|
|
let media_activity_guard = HlsTransientMediaActivityGuard::new(media_activity_marker, now_ms);
|
|
let finalizer = HlsTransientDirectResponseFinalizer::new(HlsTransientDirectResponseLifecycleContext {
|
|
hls_proxy,
|
|
session,
|
|
resource: resource.clone(),
|
|
policy,
|
|
proxy_session_id: proxy_session_id.clone(),
|
|
});
|
|
let resource_id = resource.id.0.clone();
|
|
let resource_kind = resource.kind;
|
|
let stream = futures::stream::unfold(
|
|
(
|
|
ReaderStream::new(decoded.body),
|
|
Some(guard),
|
|
origin_io_guard,
|
|
Some(media_activity_guard),
|
|
finalizer,
|
|
Box::pin(sleep(hls_client_body_send_deadline())),
|
|
false,
|
|
),
|
|
move |(
|
|
mut stream,
|
|
guard,
|
|
origin_io_guard,
|
|
media_activity_guard,
|
|
mut finalizer,
|
|
mut send_deadline,
|
|
finished,
|
|
)| {
|
|
let proxy_session_id = proxy_session_id.0.clone();
|
|
let resource_id = resource_id.clone();
|
|
async move {
|
|
if finished {
|
|
return None;
|
|
}
|
|
let next_chunk = tokio::select! {
|
|
() = send_deadline.as_mut() => {
|
|
finalizer.finish(HlsTransientDirectStreamOutcome::ClientAborted).await;
|
|
return Some((
|
|
Err(io::Error::new(io::ErrorKind::TimedOut, "hls client body send timed out")),
|
|
(stream, None, None, None, finalizer, send_deadline, true),
|
|
));
|
|
}
|
|
// Decoder setup is bounded by the absolute attempt deadline before this
|
|
// response is built. Once handed to the client, retain the existing
|
|
// per-chunk origin-body idle timeout instead of imposing a total-body limit.
|
|
next_chunk = tokio::time::timeout(body_deadline.timeout(), stream.next()) => next_chunk,
|
|
};
|
|
match next_chunk {
|
|
Ok(Some(Ok(chunk))) => {
|
|
refresh_hls_client_body_send_deadline(send_deadline.as_mut());
|
|
Some((
|
|
Ok(chunk),
|
|
(stream, guard, origin_io_guard, media_activity_guard, finalizer, send_deadline, false),
|
|
))
|
|
}
|
|
Ok(Some(Err(err))) => {
|
|
log_hls_resource_body_failure(
|
|
&proxy_session_id,
|
|
hls_transient_direct_log_context(&resource_id, resource_kind),
|
|
attempt,
|
|
&err,
|
|
body_deadline.timeout().as_millis(),
|
|
);
|
|
finalizer.finish(HlsTransientDirectStreamOutcome::OriginBodyFailure).await;
|
|
Some((Err(err), (stream, None, None, None, finalizer, send_deadline, true)))
|
|
}
|
|
Ok(None) => {
|
|
finalizer.finish(HlsTransientDirectStreamOutcome::CleanEof).await;
|
|
None
|
|
}
|
|
Err(_) => {
|
|
let error = io::Error::new(io::ErrorKind::TimedOut, "transient passthrough body timed out");
|
|
log_hls_resource_body_failure(
|
|
&proxy_session_id,
|
|
hls_transient_direct_log_context(&resource_id, resource_kind),
|
|
attempt,
|
|
&error,
|
|
body_deadline.timeout().as_millis(),
|
|
);
|
|
finalizer.finish(HlsTransientDirectStreamOutcome::OriginBodyFailure).await;
|
|
Some((Err(error), (stream, None, None, None, finalizer, send_deadline, true)))
|
|
}
|
|
}
|
|
}
|
|
},
|
|
);
|
|
Body::from_stream(stream)
|
|
}
|
|
|
|
fn hls_transient_direct_log_context(
|
|
resource_id: &str,
|
|
resource_kind: TransientResourceKind,
|
|
) -> HlsResourceFetchLogContext<'_> {
|
|
HlsResourceFetchLogContext {
|
|
kind: hls_transient_resource_fetch_kind(resource_kind),
|
|
source: HlsResourceFetchSource::Transient,
|
|
object_id: resource_id,
|
|
origin_url: None,
|
|
}
|
|
}
|
|
|
|
pub async fn record_successful_transient_segment_fetch(session: &HlsSessionHandle, resource: &TransientResourceRef) {
|
|
if resource.kind != TransientResourceKind::Segment {
|
|
return;
|
|
}
|
|
let mut session = session.write().await;
|
|
if let Some(reset_failures) = session.record_successful_segment_fetch() {
|
|
debug!(
|
|
"HLS segment temporary failure counter reset: session={} previous_failures={reset_failures}",
|
|
safe_proxy_session_id(&session.proxy_session_id)
|
|
);
|
|
}
|
|
}
|
|
|
|
pub async fn record_temporary_transient_segment_fetch_failure(
|
|
session: &HlsSessionHandle,
|
|
resource: &TransientResourceRef,
|
|
policy: &SegmentFetchPolicy,
|
|
now_ms: u64,
|
|
) -> Option<HlsAccessLeaseChannelUnavailableReason> {
|
|
if resource.kind != TransientResourceKind::Segment {
|
|
return None;
|
|
}
|
|
let mut session = session.write().await;
|
|
let threshold = session.segment_temporary_failure_threshold(policy.permanent_failure_segment_threshold);
|
|
match session.record_temporary_segment_fetch_failure(
|
|
now_ms,
|
|
HlsSegmentFailureObject::Transient { resource_id: resource.id.0.clone() },
|
|
threshold,
|
|
) {
|
|
HlsSegmentFailureTransition::StillRetryable { failures, threshold } => {
|
|
debug!(
|
|
"HLS segment temporary failure counted: session={} object={} failures={} threshold={}",
|
|
safe_proxy_session_id(&session.proxy_session_id),
|
|
resource.id.0,
|
|
failures,
|
|
threshold
|
|
);
|
|
None
|
|
}
|
|
HlsSegmentFailureTransition::BecamePermanentlyFailed { failures, threshold } => {
|
|
warn!(
|
|
"HLS segment temporary failure threshold reached: session={} failures={} threshold={}",
|
|
safe_proxy_session_id(&session.proxy_session_id),
|
|
failures,
|
|
threshold
|
|
);
|
|
session.invalidate_queued_origin_work();
|
|
Some(HlsAccessLeaseChannelUnavailableReason::TransientObjectTemporaryFailureThreshold {
|
|
failures,
|
|
threshold,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
struct HlsTransientReadGuard {
|
|
access: Arc<CacheAccessState>,
|
|
}
|
|
|
|
impl HlsTransientReadGuard {
|
|
fn new(access: Arc<CacheAccessState>, now_ms: u64) -> Self {
|
|
access.reader_started(now_ms);
|
|
Self { access }
|
|
}
|
|
}
|
|
|
|
impl Drop for HlsTransientReadGuard {
|
|
fn drop(&mut self) { self.access.reader_finished(); }
|
|
}
|
|
|
|
struct HlsTransientMediaActivityGuard {
|
|
marker: Option<HlsMediaActivityMarker>,
|
|
}
|
|
|
|
impl HlsTransientMediaActivityGuard {
|
|
fn new(marker: Option<HlsMediaActivityMarker>, _now_ms: u64) -> Self { Self { marker } }
|
|
}
|
|
|
|
impl Drop for HlsTransientMediaActivityGuard {
|
|
fn drop(&mut self) {
|
|
if let Some(marker) = &self.marker {
|
|
marker.spawn_mark_now();
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct HlsTransientOriginIoGuard {
|
|
session: HlsSessionHandle,
|
|
origin_io: HlsOriginIoContext,
|
|
lease_guard: Option<HlsOriginAccountIoLeaseGuard>,
|
|
started_generation: u64,
|
|
}
|
|
|
|
impl HlsTransientOriginIoGuard {
|
|
pub fn new(
|
|
session: HlsSessionHandle,
|
|
origin_io: HlsOriginIoContext,
|
|
lease_guard: HlsOriginAccountIoLeaseGuard,
|
|
started_generation: u64,
|
|
) -> Self {
|
|
Self { session, origin_io, lease_guard: Some(lease_guard), started_generation }
|
|
}
|
|
}
|
|
|
|
impl Drop for HlsTransientOriginIoGuard {
|
|
fn drop(&mut self) {
|
|
let Some(lease_guard) = self.lease_guard.take() else {
|
|
return;
|
|
};
|
|
let session = Arc::clone(&self.session);
|
|
let origin_io = self.origin_io.clone();
|
|
let started_generation = self.started_generation;
|
|
tokio::spawn(async move {
|
|
let generation_valid = {
|
|
let mut session = session.write().await;
|
|
session.finish_origin_work(started_generation)
|
|
};
|
|
let refresh_reservation = if generation_valid {
|
|
session.read().await.should_refresh_origin_reservation(
|
|
chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default(),
|
|
)
|
|
} else {
|
|
false
|
|
};
|
|
finish_hls_origin_account_io(&origin_io, &session, lease_guard, refresh_reservation).await;
|
|
let mut session = session.write().await;
|
|
if let Some(binding) = session.origin_account_binding.as_mut() {
|
|
let now_ms = chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default();
|
|
binding.last_origin_io_at_ms = Some(now_ms);
|
|
if refresh_reservation {
|
|
binding.last_reservation_refresh_at_ms = Some(now_ms);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() }
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
super::resource_fetch::take_body_failure_log_attempts,
|
|
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, record_temporary_transient_segment_fetch_failure,
|
|
HlsTransientCacheCommitContext, HlsTransientDecodedOriginResponse, HlsTransientDirectResponseContext,
|
|
HlsTransientDirectResponseFinalizer, HlsTransientDirectResponseLifecycleContext,
|
|
HlsTransientDirectStreamOutcome, HlsTransientObjectFetchFailure, HlsTransientOriginCacheFetchRequest,
|
|
HlsTransientOriginFetchRequest, HlsTransientOriginIoGuard,
|
|
};
|
|
use crate::{
|
|
api::{
|
|
api_utils::should_compress_response,
|
|
model::{
|
|
HlsAccessLeaseId, HlsOriginResourceClients, HlsOriginResourceFetchError, HlsProxyManager,
|
|
HlsSegmentCache, HlsSegmentFailureObject, HlsSegmentRepairManager, HlsSession, HlsSessionHandle,
|
|
HlsSessionKey, HlsSessionStore, ProxySessionId, SegmentFetchPolicy, TransientObjectCacheKey,
|
|
TransientObjectCacheStatus, TransientObjectFetchDecision, TransientResourceFile, TransientResourceKind,
|
|
TransientResourceRef,
|
|
},
|
|
},
|
|
model::HlsSegmentRepairConfig,
|
|
};
|
|
use async_compression::tokio::write::{BrotliEncoder, GzipEncoder, ZstdEncoder};
|
|
use axum::{
|
|
body::to_bytes,
|
|
http::{header, HeaderMap, HeaderValue, StatusCode},
|
|
};
|
|
use futures::FutureExt;
|
|
use shared::model::HlsSegmentRepairMode;
|
|
use std::{
|
|
io,
|
|
sync::{
|
|
atomic::{AtomicUsize, Ordering},
|
|
Arc,
|
|
},
|
|
time::Duration,
|
|
};
|
|
use tokio::{
|
|
io::{AsyncReadExt, AsyncWriteExt},
|
|
net::TcpListener,
|
|
sync::{Mutex, RwLock},
|
|
};
|
|
|
|
struct TestOrigin {
|
|
base_url: String,
|
|
requests: Arc<Mutex<Vec<String>>>,
|
|
task: tokio::task::JoinHandle<()>,
|
|
}
|
|
|
|
impl Drop for TestOrigin {
|
|
fn drop(&mut self) { self.task.abort(); }
|
|
}
|
|
|
|
#[test]
|
|
fn storage_full_transient_cache_failure_is_permanent() {
|
|
let error = HlsOriginResourceFetchError::cache_commit(&io::Error::from_raw_os_error(28));
|
|
|
|
assert!(matches!(
|
|
hls_transient_object_fetch_failure(&error),
|
|
HlsTransientObjectFetchFailure::Permanent { status: None }
|
|
));
|
|
}
|
|
|
|
async fn spawn_test_origin(
|
|
status_line: &'static str,
|
|
response_headers: Vec<(&'static str, &'static str)>,
|
|
body: Vec<u8>,
|
|
) -> TestOrigin {
|
|
spawn_test_origin_in_chunks(status_line, response_headers, vec![body], Duration::ZERO).await
|
|
}
|
|
|
|
async fn spawn_test_origin_in_chunks(
|
|
status_line: &'static str,
|
|
response_headers: Vec<(&'static str, &'static str)>,
|
|
body_chunks: Vec<Vec<u8>>,
|
|
inter_chunk_delay: Duration,
|
|
) -> TestOrigin {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds");
|
|
let addr = listener.local_addr().expect("test origin address");
|
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
let task_requests = Arc::clone(&requests);
|
|
let body_chunks = Arc::new(body_chunks);
|
|
let content_length = body_chunks.iter().map(Vec::len).sum::<usize>();
|
|
let response_headers = Arc::new(response_headers);
|
|
let task = tokio::spawn(async move {
|
|
loop {
|
|
let Ok((mut socket, _)) = listener.accept().await else {
|
|
break;
|
|
};
|
|
let requests = Arc::clone(&task_requests);
|
|
let body_chunks = Arc::clone(&body_chunks);
|
|
let response_headers = Arc::clone(&response_headers);
|
|
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 mut response = format!("HTTP/1.1 {status_line}\r\nContent-Length: {content_length}\r\n");
|
|
for (name, value) in response_headers.iter() {
|
|
response.push_str(name);
|
|
response.push_str(": ");
|
|
response.push_str(value);
|
|
response.push_str("\r\n");
|
|
}
|
|
response.push_str("Connection: close\r\n\r\n");
|
|
let _ = socket.write_all(response.as_bytes()).await;
|
|
for (index, chunk) in body_chunks.iter().enumerate() {
|
|
if index > 0 && !inter_chunk_delay.is_zero() {
|
|
tokio::time::sleep(inter_chunk_delay).await;
|
|
}
|
|
if socket.write_all(chunk).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
TestOrigin { base_url: format!("http://{addr}"), requests, task }
|
|
}
|
|
|
|
async fn spawn_retry_then_body_origin(
|
|
response_headers: Vec<(&'static str, &'static str)>,
|
|
body: Vec<u8>,
|
|
) -> TestOrigin {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds");
|
|
let addr = listener.local_addr().expect("test origin address");
|
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
let task_requests = Arc::clone(&requests);
|
|
let attempts = Arc::new(AtomicUsize::new(0));
|
|
let task_attempts = Arc::clone(&attempts);
|
|
let body = Arc::new(body);
|
|
let response_headers = Arc::new(response_headers);
|
|
let task = tokio::spawn(async move {
|
|
loop {
|
|
let Ok((mut socket, _)) = listener.accept().await else {
|
|
break;
|
|
};
|
|
let requests = Arc::clone(&task_requests);
|
|
let attempts = Arc::clone(&task_attempts);
|
|
let body = Arc::clone(&body);
|
|
let response_headers = Arc::clone(&response_headers);
|
|
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());
|
|
if attempts.fetch_add(1, Ordering::Relaxed) == 0 {
|
|
let _ = socket
|
|
.write_all(
|
|
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
|
)
|
|
.await;
|
|
return;
|
|
}
|
|
let mut response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n", body.len());
|
|
for (name, value) in response_headers.iter() {
|
|
response.push_str(name);
|
|
response.push_str(": ");
|
|
response.push_str(value);
|
|
response.push_str("\r\n");
|
|
}
|
|
response.push_str("Connection: close\r\n\r\n");
|
|
let _ = socket.write_all(response.as_bytes()).await;
|
|
let _ = socket.write_all(&body).await;
|
|
});
|
|
}
|
|
});
|
|
|
|
TestOrigin { base_url: format!("http://{addr}"), requests, task }
|
|
}
|
|
|
|
async fn spawn_zstd_origin(body: Vec<u8>) -> TestOrigin {
|
|
spawn_test_origin(
|
|
"200 OK",
|
|
vec![("Content-Encoding", "zstd"), ("Content-Type", "application/octet-stream")],
|
|
body,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn gzip_encode(body: &[u8]) -> Vec<u8> {
|
|
let mut encoder = GzipEncoder::new(Vec::new());
|
|
encoder.write_all(body).await.expect("test body encodes");
|
|
encoder.shutdown().await.expect("test encoder finishes");
|
|
encoder.into_inner()
|
|
}
|
|
|
|
async fn brotli_encode(body: &[u8]) -> Vec<u8> {
|
|
let mut encoder = BrotliEncoder::new(Vec::new());
|
|
encoder.write_all(body).await.expect("test body encodes");
|
|
encoder.shutdown().await.expect("test encoder finishes");
|
|
encoder.into_inner()
|
|
}
|
|
|
|
async fn zstd_encode(body: &[u8]) -> Vec<u8> {
|
|
let mut encoder = ZstdEncoder::new(Vec::new());
|
|
encoder.write_all(body).await.expect("test body encodes");
|
|
encoder.shutdown().await.expect("test encoder finishes");
|
|
encoder.into_inner()
|
|
}
|
|
|
|
async fn fetch_direct_decoded(
|
|
origin_url: String,
|
|
resource_kind: TransientResourceKind,
|
|
range_header: Option<HeaderValue>,
|
|
) -> Result<HlsTransientDecodedOriginResponse<Option<HlsTransientOriginIoGuard>>, HlsOriginResourceFetchError> {
|
|
fetch_direct_decoded_with_timeout(origin_url, resource_kind, range_header, 1_000).await
|
|
}
|
|
|
|
async fn fetch_direct_decoded_with_timeout(
|
|
origin_url: String,
|
|
resource_kind: TransientResourceKind,
|
|
range_header: Option<HeaderValue>,
|
|
origin_segment_timeout_ms: u64,
|
|
) -> Result<HlsTransientDecodedOriginResponse<Option<HlsTransientOriginIoGuard>>, HlsOriginResourceFetchError> {
|
|
let resource = TransientResourceRef::new(
|
|
resource_kind,
|
|
origin_url,
|
|
b"rewrite-secret",
|
|
10,
|
|
60_000,
|
|
Some("bin".to_string()),
|
|
);
|
|
let request = HlsTransientOriginFetchRequest {
|
|
resolved_origin_uri: resource.resolved_origin_uri.clone(),
|
|
origin_headers: HeaderMap::new(),
|
|
origin_provider_session_headers: HeaderMap::new(),
|
|
range_header,
|
|
resource_file: TransientResourceFile { resource_id: resource.id, extension: "bin".to_string() },
|
|
resource_kind,
|
|
clients: HlsOriginResourceClients {
|
|
client: reqwest::Client::new(),
|
|
no_redirect_client: reqwest::Client::builder()
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()
|
|
.expect("no-redirect client builds"),
|
|
use_manual_redirects: false,
|
|
},
|
|
policy: SegmentFetchPolicy {
|
|
origin_segment_timeout_ms,
|
|
retry_delays_ms: [0; 5],
|
|
retry_jitter_max_ms: 0,
|
|
..SegmentFetchPolicy::default()
|
|
},
|
|
session_log_id: "direct-transient-content-coding-test".to_string(),
|
|
};
|
|
fetch_hls_transient_origin_response_with_attempt_prepare(request, |_| async { Ok(None) }.boxed()).await
|
|
}
|
|
|
|
fn direct_client_response(
|
|
response: HlsTransientDecodedOriginResponse<Option<HlsTransientOriginIoGuard>>,
|
|
resource_kind: TransientResourceKind,
|
|
) -> axum::response::Response {
|
|
TestDirectResponseFixture::new(resource_kind, "http://127.0.0.1/origin").response(response)
|
|
}
|
|
|
|
struct TestDirectResponseFixture {
|
|
hls_proxy: Arc<HlsProxyManager>,
|
|
session: HlsSessionHandle,
|
|
resource: TransientResourceRef,
|
|
policy: SegmentFetchPolicy,
|
|
proxy_session_id: ProxySessionId,
|
|
}
|
|
|
|
impl TestDirectResponseFixture {
|
|
fn new(resource_kind: TransientResourceKind, origin_url: impl Into<String>) -> Self {
|
|
let resource = TransientResourceRef::new(
|
|
resource_kind,
|
|
origin_url,
|
|
b"rewrite-secret",
|
|
10,
|
|
60_000,
|
|
Some("bin".to_string()),
|
|
);
|
|
let policy =
|
|
SegmentFetchPolicy { retry_delays_ms: [0; 5], retry_jitter_max_ms: 0, ..SegmentFetchPolicy::default() };
|
|
let session = HlsSession::new(HlsSessionKey::new(1, "direct-test"), b"rewrite-secret", 10);
|
|
let proxy_session_id = session.proxy_session_id.clone();
|
|
Self {
|
|
hls_proxy: Arc::new(HlsProxyManager::new()),
|
|
session: Arc::new(RwLock::new(session)),
|
|
resource,
|
|
policy,
|
|
proxy_session_id,
|
|
}
|
|
}
|
|
|
|
fn response(
|
|
&self,
|
|
response: HlsTransientDecodedOriginResponse<Option<HlsTransientOriginIoGuard>>,
|
|
) -> axum::response::Response {
|
|
hls_transient_origin_response(response, self.response_context())
|
|
}
|
|
|
|
fn response_context(&self) -> HlsTransientDirectResponseContext {
|
|
HlsTransientDirectResponseContext {
|
|
hls_proxy: Arc::clone(&self.hls_proxy),
|
|
session: Arc::clone(&self.session),
|
|
resource: self.resource.clone(),
|
|
policy: self.policy.clone(),
|
|
media_activity_marker: None,
|
|
now_ms: 10,
|
|
proxy_session_id: self.proxy_session_id.clone(),
|
|
}
|
|
}
|
|
|
|
fn lifecycle_context(&self) -> HlsTransientDirectResponseLifecycleContext {
|
|
HlsTransientDirectResponseLifecycleContext {
|
|
hls_proxy: Arc::clone(&self.hls_proxy),
|
|
session: Arc::clone(&self.session),
|
|
resource: self.resource.clone(),
|
|
policy: self.policy.clone(),
|
|
proxy_session_id: self.proxy_session_id.clone(),
|
|
}
|
|
}
|
|
|
|
async fn seed_segment_failure(&self) {
|
|
assert!(record_temporary_transient_segment_fetch_failure(&self.session, &self.resource, &self.policy, 9)
|
|
.await
|
|
.is_none());
|
|
}
|
|
|
|
async fn segment_failure_count(&self) -> u32 {
|
|
self.session.read().await.segment_failure_tracker.consecutive_temporary_failures
|
|
}
|
|
|
|
async fn wait_for_segment_failure_count(&self, expected: u32) {
|
|
tokio::time::timeout(Duration::from_secs(1), async {
|
|
loop {
|
|
if self.segment_failure_count().await == expected {
|
|
break;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("segment failure state reaches expected value");
|
|
}
|
|
|
|
async fn last_segment_failure(&self) -> Option<HlsSegmentFailureObject> {
|
|
self.session.read().await.segment_failure_tracker.last_failed_object.clone()
|
|
}
|
|
}
|
|
|
|
struct TestTransientCacheFixture {
|
|
_temp_dir: tempfile::TempDir,
|
|
segment_cache: Arc<HlsSegmentCache>,
|
|
segment_repair: Arc<HlsSegmentRepairManager>,
|
|
session: HlsSessionHandle,
|
|
proxy_session_id: ProxySessionId,
|
|
resource: TransientResourceRef,
|
|
resource_file: TransientResourceFile,
|
|
cache_key: TransientObjectCacheKey,
|
|
}
|
|
|
|
impl TestTransientCacheFixture {
|
|
async fn new(origin_url: String) -> Self {
|
|
let resource = TransientResourceRef::new(
|
|
TransientResourceKind::Segment,
|
|
origin_url,
|
|
b"rewrite-secret",
|
|
10,
|
|
60_000,
|
|
Some("ts".to_string()),
|
|
);
|
|
let resource_file = TransientResourceFile { resource_id: resource.id.clone(), extension: "ts".to_string() };
|
|
let store = HlsSessionStore::new();
|
|
let session = store.get_or_create_session(HlsSessionKey::new(1, "1"), b"rewrite-secret", 10).await;
|
|
let proxy_session_id = session.read().await.proxy_session_id.clone();
|
|
let cache_key = {
|
|
let mut session = session.write().await;
|
|
match session.transient.begin_object_fetch(&proxy_session_id, &resource, "ts", 10, 60_000) {
|
|
TransientObjectFetchDecision::Fetch(cache_key) => cache_key,
|
|
TransientObjectFetchDecision::Ready | TransientObjectFetchDecision::Wait(_) => {
|
|
panic!("new transient resource should start a cache fetch")
|
|
}
|
|
}
|
|
};
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let segment_cache = Arc::new(HlsSegmentCache::with_cache_path(temp_dir.path()));
|
|
let segment_repair = Arc::new(HlsSegmentRepairManager::new(HlsSegmentRepairConfig {
|
|
max_level: HlsSegmentRepairMode::Off,
|
|
apply_to_first_segments: 1,
|
|
max_parallel_repairs: 1,
|
|
..Default::default()
|
|
}));
|
|
Self {
|
|
_temp_dir: temp_dir,
|
|
segment_cache,
|
|
segment_repair,
|
|
session,
|
|
proxy_session_id,
|
|
resource,
|
|
resource_file,
|
|
cache_key,
|
|
}
|
|
}
|
|
|
|
fn request(&self, range_header: Option<HeaderValue>) -> HlsTransientOriginCacheFetchRequest {
|
|
HlsTransientOriginCacheFetchRequest {
|
|
fetch: HlsTransientOriginFetchRequest {
|
|
resolved_origin_uri: self.resource.resolved_origin_uri.clone(),
|
|
origin_headers: HeaderMap::new(),
|
|
origin_provider_session_headers: HeaderMap::new(),
|
|
range_header,
|
|
resource_file: self.resource_file.clone(),
|
|
resource_kind: self.resource.kind,
|
|
clients: HlsOriginResourceClients {
|
|
client: reqwest::Client::new(),
|
|
no_redirect_client: reqwest::Client::builder()
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()
|
|
.expect("no-redirect client builds"),
|
|
use_manual_redirects: false,
|
|
},
|
|
policy: SegmentFetchPolicy {
|
|
origin_segment_timeout_ms: 1_000,
|
|
retry_delays_ms: [0; 5],
|
|
retry_jitter_max_ms: 0,
|
|
..SegmentFetchPolicy::default()
|
|
},
|
|
session_log_id: self.proxy_session_id.0.clone(),
|
|
},
|
|
commit: HlsTransientCacheCommitContext {
|
|
segment_cache: Arc::clone(&self.segment_cache),
|
|
segment_repair: Arc::clone(&self.segment_repair),
|
|
session: Arc::clone(&self.session),
|
|
access_lease_id: HlsAccessLeaseId("transient-content-coding-test".to_string()),
|
|
resource: self.resource.clone(),
|
|
resource_file: self.resource_file.clone(),
|
|
cache_key: self.cache_key.clone(),
|
|
cache_duration_ms: 60_000,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DropCounter(Arc<AtomicUsize>);
|
|
|
|
impl Drop for DropCounter {
|
|
fn drop(&mut self) { self.0.fetch_add(1, Ordering::Relaxed); }
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_key_decodes_declared_gzip_brotli_and_zstd_to_exact_identity_bytes() {
|
|
let identity = b"\x00\xffdirect-key-identity\x10\x80";
|
|
let encoded_bodies = [
|
|
("gzip", gzip_encode(identity).await),
|
|
("br", brotli_encode(identity).await),
|
|
("zstd", zstd_encode(identity).await),
|
|
];
|
|
|
|
for (encoding, encoded) in encoded_bodies {
|
|
let origin = spawn_test_origin(
|
|
"200 OK",
|
|
vec![("Content-Encoding", encoding), ("Content-Type", "application/octet-stream")],
|
|
encoded,
|
|
)
|
|
.await;
|
|
let decoded =
|
|
fetch_direct_decoded(format!("{}/key.bin", origin.base_url), TransientResourceKind::Key, None)
|
|
.await
|
|
.expect("declared direct key coding decodes");
|
|
let response = direct_client_response(decoded, TransientResourceKind::Key);
|
|
|
|
assert!(!should_compress_response(&response));
|
|
assert!(response.headers().get(header::CONTENT_ENCODING).is_none());
|
|
assert!(response.headers().get(header::CONTENT_LENGTH).is_none());
|
|
assert_eq!(
|
|
to_bytes(response.into_body(), usize::MAX).await.expect("decoded key body streams"),
|
|
identity.as_slice()
|
|
);
|
|
|
|
let requests = origin.requests.lock().await;
|
|
assert_eq!(requests.len(), 1);
|
|
assert!(requests[0].to_ascii_lowercase().contains("accept-encoding: identity"));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_map_and_part_or_other_resources_decode_to_identity() {
|
|
let identity = b"shared-direct-map-part-other";
|
|
for (resource_kind, encoding, encoded) in [
|
|
(TransientResourceKind::Map, "gzip", gzip_encode(identity).await),
|
|
(TransientResourceKind::Part, "br", brotli_encode(identity).await),
|
|
(TransientResourceKind::Other, "zstd", zstd_encode(identity).await),
|
|
] {
|
|
let origin = spawn_test_origin(
|
|
"200 OK",
|
|
vec![("Content-Encoding", encoding), ("Content-Type", "application/octet-stream")],
|
|
encoded,
|
|
)
|
|
.await;
|
|
let decoded = fetch_direct_decoded(format!("{}/object.bin", origin.base_url), resource_kind, None)
|
|
.await
|
|
.expect("declared direct resource coding decodes");
|
|
let response = direct_client_response(decoded, resource_kind);
|
|
|
|
assert_eq!(
|
|
to_bytes(response.into_body(), usize::MAX).await.expect("decoded resource body streams"),
|
|
identity.as_slice()
|
|
);
|
|
assert_eq!(origin.requests.lock().await.len(), 1);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_binary_declared_only_leaves_headerless_gzip_magic_unchanged() {
|
|
let identity = b"headerless-gzip-representation";
|
|
let headerless_encoded = gzip_encode(identity).await;
|
|
let random_magic = vec![0x1f, 0x8b, 0x11, 0x00, 0xff, 0x42, 0x7e];
|
|
|
|
for body in [headerless_encoded, random_magic] {
|
|
let origin =
|
|
spawn_test_origin("200 OK", vec![("Content-Type", "application/octet-stream")], body.clone()).await;
|
|
let decoded =
|
|
fetch_direct_decoded(format!("{}/key.bin", origin.base_url), TransientResourceKind::Key, None)
|
|
.await
|
|
.expect("headerless binary is not inspected");
|
|
let response = direct_client_response(decoded, TransientResourceKind::Key);
|
|
|
|
assert_eq!(to_bytes(response.into_body(), usize::MAX).await.expect("headerless binary streams"), body);
|
|
assert_eq!(origin.requests.lock().await.len(), 1);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn decoded_direct_response_normalizes_headers_and_disables_tower_compression() {
|
|
let identity = b"normalized-direct-response";
|
|
let encoded = gzip_encode(identity).await;
|
|
let origin = spawn_test_origin(
|
|
"200 OK",
|
|
vec![
|
|
("Content-Encoding", "gzip"),
|
|
("Content-Type", "application/octet-stream"),
|
|
("Content-Range", "bytes 0-9/10"),
|
|
("Accept-Ranges", "bytes"),
|
|
("Cache-Control", "private, max-age=5"),
|
|
("ETag", "strong-origin-representation"),
|
|
("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT"),
|
|
("Set-Cookie", "provider_session=secret"),
|
|
("X-Provider-Secret", "do-not-forward"),
|
|
],
|
|
encoded,
|
|
)
|
|
.await;
|
|
let decoded = fetch_direct_decoded(format!("{}/key.bin", origin.base_url), TransientResourceKind::Key, None)
|
|
.await
|
|
.expect("direct response decodes");
|
|
let response = direct_client_response(decoded, TransientResourceKind::Key);
|
|
let headers = response.headers();
|
|
|
|
assert!(!should_compress_response(&response));
|
|
for removed in [
|
|
header::CONTENT_ENCODING,
|
|
header::CONTENT_LENGTH,
|
|
header::CONTENT_RANGE,
|
|
header::ACCEPT_RANGES,
|
|
header::ETAG,
|
|
header::TRANSFER_ENCODING,
|
|
header::SET_COOKIE,
|
|
] {
|
|
assert!(headers.get(&removed).is_none(), "{removed} must not describe the decoded response");
|
|
}
|
|
assert!(headers.get("x-provider-secret").is_none());
|
|
assert_eq!(headers.get(header::CONTENT_TYPE).expect("content type"), "application/octet-stream");
|
|
assert_eq!(headers.get(header::CACHE_CONTROL).expect("cache control"), "private, max-age=5");
|
|
assert_eq!(
|
|
to_bytes(response.into_body(), usize::MAX).await.expect("normalized body streams"),
|
|
identity.as_slice()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_encoded_partial_content_is_rejected_before_client_response() {
|
|
let encoded = zstd_encode(b"encoded-range").await;
|
|
let origin = spawn_test_origin(
|
|
"206 Partial Content",
|
|
vec![
|
|
("Content-Encoding", "zstd"),
|
|
("Content-Type", "application/octet-stream"),
|
|
("Content-Range", "bytes 2-5/16"),
|
|
],
|
|
encoded,
|
|
)
|
|
.await;
|
|
|
|
let result = fetch_direct_decoded(
|
|
format!("{}/key.bin", origin.base_url),
|
|
TransientResourceKind::Key,
|
|
Some(HeaderValue::from_static("bytes=2-5")),
|
|
)
|
|
.await;
|
|
|
|
assert!(matches!(result, Err(HlsOriginResourceFetchError::ContentCoding(_))));
|
|
let requests = origin.requests.lock().await;
|
|
assert_eq!(requests.len(), 1);
|
|
let request = requests[0].to_ascii_lowercase();
|
|
assert!(request.contains("accept-encoding: identity"));
|
|
assert!(request.contains("range: bytes=2-5"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_identity_partial_content_preserves_consistent_range_headers() {
|
|
let identity = b"part";
|
|
let origin = spawn_test_origin(
|
|
"206 Partial Content",
|
|
vec![
|
|
("Content-Type", "application/octet-stream"),
|
|
("Content-Range", "bytes 2-5/16"),
|
|
("Accept-Ranges", "bytes"),
|
|
],
|
|
identity.to_vec(),
|
|
)
|
|
.await;
|
|
let decoded = fetch_direct_decoded(
|
|
format!("{}/key.bin", origin.base_url),
|
|
TransientResourceKind::Key,
|
|
Some(HeaderValue::from_static("bytes=2-5")),
|
|
)
|
|
.await
|
|
.expect("identity partial response is allowed");
|
|
let response = direct_client_response(decoded, TransientResourceKind::Key);
|
|
|
|
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
|
assert_eq!(response.headers().get(header::CONTENT_LENGTH).expect("content length"), "4");
|
|
assert_eq!(response.headers().get(header::CONTENT_RANGE).expect("content range"), "bytes 2-5/16");
|
|
assert_eq!(response.headers().get(header::ACCEPT_RANGES).expect("accept ranges"), "bytes");
|
|
assert!(!should_compress_response(&response));
|
|
assert_eq!(
|
|
to_bytes(response.into_body(), usize::MAX).await.expect("identity partial body streams"),
|
|
identity.as_slice()
|
|
);
|
|
assert_eq!(origin.requests.lock().await.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_fetch_propagates_retry_attempt_and_identity_to_client_response() {
|
|
let identity = b"successful-second-attempt";
|
|
let origin =
|
|
spawn_retry_then_body_origin(vec![("Content-Type", "application/octet-stream")], identity.to_vec()).await;
|
|
let origin_url = format!("{}/segment.ts", origin.base_url);
|
|
let decoded = fetch_direct_decoded(origin_url.clone(), TransientResourceKind::Segment, None)
|
|
.await
|
|
.expect("second origin attempt succeeds");
|
|
|
|
assert_eq!(decoded.attempt.attempt_index, 1);
|
|
assert_eq!(decoded.attempt.attempts, 5);
|
|
|
|
let fixture = TestDirectResponseFixture::new(TransientResourceKind::Segment, origin_url);
|
|
let response = fixture.response(decoded);
|
|
assert_eq!(
|
|
to_bytes(response.into_body(), usize::MAX).await.expect("retried response body streams"),
|
|
identity.as_slice()
|
|
);
|
|
|
|
let requests = origin.requests.lock().await;
|
|
assert_eq!(requests.len(), 2);
|
|
assert!(requests.iter().all(|request| request.to_ascii_lowercase().contains("accept-encoding: identity")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_segment_success_resets_failure_state_only_after_clean_eof() {
|
|
let identity = b"complete-segment";
|
|
let origin =
|
|
spawn_test_origin("200 OK", vec![("Content-Type", "application/octet-stream")], identity.to_vec()).await;
|
|
let origin_url = format!("{}/segment.ts", origin.base_url);
|
|
let fixture = TestDirectResponseFixture::new(TransientResourceKind::Segment, origin_url.clone());
|
|
fixture.seed_segment_failure().await;
|
|
let decoded = fetch_direct_decoded(origin_url, TransientResourceKind::Segment, None)
|
|
.await
|
|
.expect("direct response setup succeeds");
|
|
let response = fixture.response(decoded);
|
|
|
|
assert_eq!(fixture.segment_failure_count().await, 1, "headers alone must not report body success");
|
|
assert_eq!(fixture.resource.access.active_readers(), 1);
|
|
assert_eq!(
|
|
to_bytes(response.into_body(), usize::MAX).await.expect("complete segment streams"),
|
|
identity.as_slice()
|
|
);
|
|
fixture.wait_for_segment_failure_count(0).await;
|
|
assert_eq!(fixture.resource.access.active_readers(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_terminal_transition_survives_dropped_completion_waiter() {
|
|
let fixture =
|
|
TestDirectResponseFixture::new(TransientResourceKind::Segment, "http://127.0.0.1/cancelled-body-poll.ts");
|
|
fixture.seed_segment_failure().await;
|
|
let session_lock = fixture.session.write().await;
|
|
let mut finalizer = HlsTransientDirectResponseFinalizer::new(fixture.lifecycle_context());
|
|
|
|
let transition = finalizer
|
|
.begin_finish(HlsTransientDirectStreamOutcome::CleanEof)
|
|
.expect("clean EOF starts an owned transition");
|
|
drop(finalizer);
|
|
drop(transition);
|
|
drop(session_lock);
|
|
|
|
fixture.wait_for_segment_failure_count(0).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_segment_decoder_failure_after_retry_uses_selected_attempt_once() {
|
|
let _ = take_body_failure_log_attempts();
|
|
let mut truncated = gzip_encode(b"decoder failure after response headers").await;
|
|
truncated.truncate(truncated.len().saturating_sub(8));
|
|
let origin = spawn_retry_then_body_origin(
|
|
vec![("Content-Encoding", "gzip"), ("Content-Type", "application/octet-stream")],
|
|
truncated,
|
|
)
|
|
.await;
|
|
let origin_url = format!("{}/segment.ts", origin.base_url);
|
|
let fixture = TestDirectResponseFixture::new(TransientResourceKind::Segment, origin_url.clone());
|
|
let decoded = fetch_direct_decoded(origin_url, TransientResourceKind::Segment, None)
|
|
.await
|
|
.expect("decoder setup succeeds before streaming");
|
|
assert_eq!(decoded.attempt.attempt_index, 1);
|
|
assert_eq!(decoded.attempt.attempts, 5);
|
|
let response = fixture.response(decoded);
|
|
|
|
assert!(to_bytes(response.into_body(), usize::MAX).await.is_err());
|
|
fixture.wait_for_segment_failure_count(1).await;
|
|
assert_eq!(
|
|
fixture.last_segment_failure().await,
|
|
Some(HlsSegmentFailureObject::Transient { resource_id: fixture.resource.id.0.clone() })
|
|
);
|
|
tokio::task::yield_now().await;
|
|
assert_eq!(fixture.segment_failure_count().await, 1, "terminal body failure must not finalize twice");
|
|
assert_eq!(fixture.resource.access.active_readers(), 0);
|
|
assert_eq!(origin.requests.lock().await.len(), 2, "streaming failure must not trigger a hidden retry");
|
|
assert_eq!(
|
|
take_body_failure_log_attempts(),
|
|
vec![(1, 5)],
|
|
"test hook intentionally records the zero-based raw index of the selected second attempt"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_non_segment_finalizers_do_not_start_lifecycle_tasks() {
|
|
for kind in [
|
|
TransientResourceKind::Key,
|
|
TransientResourceKind::Map,
|
|
TransientResourceKind::Part,
|
|
TransientResourceKind::Other,
|
|
] {
|
|
for outcome in
|
|
[HlsTransientDirectStreamOutcome::CleanEof, HlsTransientDirectStreamOutcome::OriginBodyFailure]
|
|
{
|
|
let fixture = TestDirectResponseFixture::new(kind, "http://127.0.0.1/non-segment.bin");
|
|
let mut finalizer = HlsTransientDirectResponseFinalizer::new(fixture.lifecycle_context());
|
|
|
|
assert!(finalizer.begin_finish(outcome).is_none(), "{kind:?} must not start a segment lifecycle task");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_segment_origin_idle_timeout_is_counted_exactly_once() {
|
|
let origin = spawn_test_origin_in_chunks(
|
|
"200 OK",
|
|
vec![("Content-Type", "application/octet-stream")],
|
|
vec![b"first".to_vec(), b"late".to_vec()],
|
|
Duration::from_millis(200),
|
|
)
|
|
.await;
|
|
let origin_url = format!("{}/segment.ts", origin.base_url);
|
|
let fixture = TestDirectResponseFixture::new(TransientResourceKind::Segment, origin_url.clone());
|
|
let decoded = fetch_direct_decoded_with_timeout(origin_url, TransientResourceKind::Segment, None, 50)
|
|
.await
|
|
.expect("direct response setup succeeds");
|
|
let response = fixture.response(decoded);
|
|
|
|
assert!(to_bytes(response.into_body(), usize::MAX).await.is_err());
|
|
fixture.wait_for_segment_failure_count(1).await;
|
|
tokio::task::yield_now().await;
|
|
assert_eq!(fixture.segment_failure_count().await, 1);
|
|
assert_eq!(fixture.resource.access.active_readers(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dropping_direct_segment_body_is_client_abort_without_state_transition() {
|
|
let _ = take_body_failure_log_attempts();
|
|
let origin = spawn_test_origin(
|
|
"200 OK",
|
|
vec![("Content-Type", "application/octet-stream")],
|
|
b"unconsumed-segment".to_vec(),
|
|
)
|
|
.await;
|
|
let origin_url = format!("{}/segment.ts", origin.base_url);
|
|
let fixture = TestDirectResponseFixture::new(TransientResourceKind::Segment, origin_url.clone());
|
|
fixture.seed_segment_failure().await;
|
|
let decoded = fetch_direct_decoded(origin_url, TransientResourceKind::Segment, None)
|
|
.await
|
|
.expect("direct response setup succeeds");
|
|
let response = fixture.response(decoded);
|
|
assert_eq!(fixture.resource.access.active_readers(), 1);
|
|
|
|
drop(response);
|
|
|
|
assert_eq!(fixture.segment_failure_count().await, 1);
|
|
assert_eq!(fixture.resource.access.active_readers(), 0);
|
|
assert_eq!(origin.requests.lock().await.len(), 1);
|
|
assert!(take_body_failure_log_attempts().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_non_segment_body_failures_are_failure_tracker_neutral() {
|
|
for kind in [
|
|
TransientResourceKind::Key,
|
|
TransientResourceKind::Map,
|
|
TransientResourceKind::Part,
|
|
TransientResourceKind::Other,
|
|
] {
|
|
let mut truncated = gzip_encode(b"non-segment decoder failure").await;
|
|
truncated.truncate(truncated.len().saturating_sub(8));
|
|
let origin = spawn_test_origin(
|
|
"200 OK",
|
|
vec![("Content-Encoding", "gzip"), ("Content-Type", "application/octet-stream")],
|
|
truncated,
|
|
)
|
|
.await;
|
|
let origin_url = format!("{}/object.bin", origin.base_url);
|
|
let fixture = TestDirectResponseFixture::new(kind, origin_url.clone());
|
|
let decoded =
|
|
fetch_direct_decoded(origin_url, kind, None).await.expect("decoder setup succeeds before streaming");
|
|
let response = fixture.response(decoded);
|
|
|
|
assert!(to_bytes(response.into_body(), usize::MAX).await.is_err());
|
|
assert_eq!(fixture.segment_failure_count().await, 0, "{kind:?} must not affect segment failure state");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_stream_decoder_failure_aborts_body_without_origin_retry() {
|
|
let mut truncated = gzip_encode(b"decoder failure after response headers").await;
|
|
truncated.truncate(truncated.len().saturating_sub(8));
|
|
let origin = spawn_test_origin(
|
|
"200 OK",
|
|
vec![("Content-Encoding", "gzip"), ("Content-Type", "application/octet-stream")],
|
|
truncated,
|
|
)
|
|
.await;
|
|
let decoded = fetch_direct_decoded(format!("{}/key.bin", origin.base_url), TransientResourceKind::Key, None)
|
|
.await
|
|
.expect("decoder setup succeeds before streaming");
|
|
let response = direct_client_response(decoded, TransientResourceKind::Key);
|
|
|
|
assert!(to_bytes(response.into_body(), usize::MAX).await.is_err());
|
|
assert_eq!(origin.requests.lock().await.len(), 1, "streaming errors must not trigger a hidden retry");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_stream_refreshes_origin_body_idle_deadline_after_each_chunk() {
|
|
const BODY_IDLE_TIMEOUT_MS: u64 = 500;
|
|
let chunks = [b"slow-".to_vec(), b"but-".to_vec(), b"continuous-".to_vec(), b"body".to_vec()];
|
|
let expected = chunks.concat();
|
|
let origin = spawn_test_origin_in_chunks(
|
|
"200 OK",
|
|
vec![("Content-Type", "application/octet-stream")],
|
|
chunks.into(),
|
|
Duration::from_millis(200),
|
|
)
|
|
.await;
|
|
let decoded = fetch_direct_decoded_with_timeout(
|
|
format!("{}/key.bin", origin.base_url),
|
|
TransientResourceKind::Key,
|
|
None,
|
|
BODY_IDLE_TIMEOUT_MS,
|
|
)
|
|
.await
|
|
.expect("direct response setup succeeds");
|
|
let response = direct_client_response(decoded, TransientResourceKind::Key);
|
|
|
|
assert_eq!(
|
|
to_bytes(response.into_body(), usize::MAX).await.expect("progressing body outlives total timeout"),
|
|
expected
|
|
);
|
|
assert_eq!(origin.requests.lock().await.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cacheable_transient_rejects_identity_partial_response_before_commit_and_cleans_guard() {
|
|
let origin = spawn_test_origin(
|
|
"206 Partial Content",
|
|
vec![("Content-Type", "video/mp2t"), ("Content-Range", "bytes 0-3/10")],
|
|
b"part".to_vec(),
|
|
)
|
|
.await;
|
|
let fixture = TestTransientCacheFixture::new(format!("{}/partial.ts", origin.base_url)).await;
|
|
let dropped_guards = Arc::new(AtomicUsize::new(0));
|
|
let dropped_guards_for_prepare = Arc::clone(&dropped_guards);
|
|
|
|
let result =
|
|
fetch_and_commit_hls_transient_origin_response_with_attempt_prepare(fixture.request(None), move |_| {
|
|
let dropped_guards = Arc::clone(&dropped_guards_for_prepare);
|
|
async move { Ok(DropCounter(dropped_guards)) }.boxed()
|
|
})
|
|
.await;
|
|
|
|
assert!(matches!(result, Err(HlsOriginResourceFetchError::UnexpectedByteRangeStatus)));
|
|
assert_eq!(dropped_guards.load(Ordering::Relaxed), 1);
|
|
assert!(fixture.segment_cache.metadata(&fixture.cache_key).await.expect("cache metadata reads").is_none());
|
|
assert!(!fixture.segment_cache.has_active_temp_files().await);
|
|
assert_eq!(std::fs::read_dir(fixture._temp_dir.path()).expect("cache root reads").count(), 0);
|
|
let session = fixture.session.read().await;
|
|
let entry = session.transient.object_cache.get(&fixture.cache_key).expect("fetching cache entry remains");
|
|
assert!(!matches!(entry.status, TransientObjectCacheStatus::Ready { .. }));
|
|
drop(session);
|
|
|
|
let requests = origin.requests.lock().await;
|
|
assert_eq!(requests.len(), 1);
|
|
let request = requests[0].to_ascii_lowercase();
|
|
assert!(request.contains("accept-encoding: identity"));
|
|
assert!(!request.contains("\r\nrange:"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cacheable_transient_without_range_decodes_to_identity_before_cache_and_range_reads() {
|
|
let ciphertext =
|
|
vec![0x00, 0xff, 0x92, 0x10, 0x7e, 0x33, 0xc4, 0x81, 0xa8, 0x09, 0x5d, 0xe2, 0x44, 0x17, 0xb0, 0x6f];
|
|
assert!(std::str::from_utf8(&ciphertext).is_err());
|
|
let encoded = zstd_encode(&ciphertext).await;
|
|
assert_ne!(encoded, ciphertext);
|
|
let origin = spawn_zstd_origin(encoded).await;
|
|
let fixture = TestTransientCacheFixture::new(format!("{}/cipher.ts", origin.base_url)).await;
|
|
|
|
fetch_and_commit_hls_transient_origin_response_with_attempt_prepare(fixture.request(None), |_| {
|
|
async { Ok(()) }.boxed()
|
|
})
|
|
.await
|
|
.expect("encoded transient cache fetch succeeds");
|
|
|
|
let metadata = fixture
|
|
.segment_cache
|
|
.metadata(&fixture.cache_key)
|
|
.await
|
|
.expect("cache metadata reads")
|
|
.expect("decoded object is cached");
|
|
assert_eq!(metadata.size, ciphertext.len() as u64);
|
|
assert_eq!(tokio::fs::read(&metadata.path).await.expect("cache body reads"), ciphertext);
|
|
let mut range = Vec::new();
|
|
fixture
|
|
.segment_cache
|
|
.open_range(&fixture.cache_key, 5)
|
|
.await
|
|
.expect("decoded cache range opens")
|
|
.take(4)
|
|
.read_to_end(&mut range)
|
|
.await
|
|
.expect("decoded cache range reads");
|
|
assert_eq!(range, ciphertext[5..9]);
|
|
|
|
let session = fixture.session.read().await;
|
|
let entry = session.transient.object_cache.get(&fixture.cache_key).expect("transient cache entry");
|
|
assert!(matches!(
|
|
entry.status,
|
|
TransientObjectCacheStatus::Ready { content_length, .. }
|
|
if content_length == ciphertext.len() as u64
|
|
));
|
|
assert_eq!(entry.content_type, "application/octet-stream");
|
|
drop(session);
|
|
|
|
let requests = origin.requests.lock().await;
|
|
assert_eq!(requests.len(), 1);
|
|
let request = requests[0].to_ascii_lowercase();
|
|
assert!(request.contains("accept-encoding: identity"));
|
|
assert!(!request.contains("\r\nrange:"));
|
|
}
|
|
}
|