From b946d397ddd3327b0f3b65f1ce810844d1aaf84e Mon Sep 17 00:00:00 2001 From: zijiren <84728412+zijiren233@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:15:27 +0800 Subject: [PATCH] fix(proxy): preserve seek range requests after inconclusive HEAD (#437) ## Summary - keep non-zero Range requests when a HEAD response does not advertise Accept-Ranges - use slice caching when the upstream answers 206 and preserve full-response fallback when it answers 200 - add regression coverage for inconclusive HEAD probes and range fallback behavior ## Validation - cargo test -p synctv-proxy --test slice_cache_tests (94 passed) - cargo check --workspace --all-targets - cargo fmt --check - verified Alist, Emby, and Bilibili proxy seeks return correct 206 responses and byte ranges --- synctv-proxy/src/slice_cache/etag.rs | 1 + synctv-proxy/src/slice_cache/filter.rs | 16 --------- synctv-proxy/tests/slice_cache_tests.rs | 44 +++++++++++++------------ 3 files changed, 24 insertions(+), 37 deletions(-) diff --git a/synctv-proxy/src/slice_cache/etag.rs b/synctv-proxy/src/slice_cache/etag.rs index 6a5751aa..58103163 100644 --- a/synctv-proxy/src/slice_cache/etag.rs +++ b/synctv-proxy/src/slice_cache/etag.rs @@ -24,6 +24,7 @@ pub struct CachedResourceMeta { /// Total size of the resource as reported by upstream. pub total_size: Option, /// Whether upstream has proven byte-range support for this resource. + /// `false` means unknown and must not suppress a later range probe. pub supports_ranges: bool, /// Content-Type of the resource. pub content_type: Option, diff --git a/synctv-proxy/src/slice_cache/filter.rs b/synctv-proxy/src/slice_cache/filter.rs index 5c26b1b2..20672411 100644 --- a/synctv-proxy/src/slice_cache/filter.rs +++ b/synctv-proxy/src/slice_cache/filter.rs @@ -425,22 +425,6 @@ async fn proxy_slice_cache( } let cached_meta = cache.get_resource_meta(url, provider_headers); - if cached_meta - .as_ref() - .is_some_and(|meta| !meta.supports_ranges) - { - return stream_through_with_status(StreamThroughRequest { - client: cache.client(), - ssrf_guard: cache.ssrf_guard(), - url, - provider_headers, - range_header: None, - cache_status: CacheStatus::Bypass, - request_control, - upstream_header_timeout, - }) - .await; - } let known_total_size = cached_meta.and_then(|meta| meta.total_size); if let Some(total_size) = known_total_size { range_bounds_for_total(plan, total_size).map_err(proxy_error_from_client_range_error)?; diff --git a/synctv-proxy/tests/slice_cache_tests.rs b/synctv-proxy/tests/slice_cache_tests.rs index 8beb81d0..5762242b 100644 --- a/synctv-proxy/tests/slice_cache_tests.rs +++ b/synctv-proxy/tests/slice_cache_tests.rs @@ -1094,10 +1094,10 @@ async fn test_proxy_head_with_cache_uses_head_and_reuses_cached_metadata() { } #[tokio::test] -async fn test_head_without_accept_ranges_stores_length_without_range_support() { +async fn test_head_without_accept_ranges_does_not_block_later_range_probe() { let mock_server = MockServer::start().await; let total_size: u64 = 4096; - let full_body = Bytes::from(vec![0xE1; 4096]); + let slice_body = Bytes::from((0_u8..=u8::MAX).cycle().take(1024).collect::>()); Mock::given(method("HEAD")) .and(path("/head-no-range.bin")) @@ -1112,19 +1112,12 @@ async fn test_head_without_accept_ranges_stores_length_without_range_support() { Mock::given(method("GET")) .and(path("/head-no-range.bin")) - .and(header("Range", "bytes=0-1023")) - .respond_with(ResponseTemplate::new(500)) - .expect(0) - .mount(&mock_server) - .await; - - Mock::given(method("GET")) - .and(path("/head-no-range.bin")) - .and(HeaderAbsent("Range")) + .and(header("Range", "bytes=2048-3071")) .respond_with( - ResponseTemplate::new(200) - .set_body_bytes(full_body.clone()) - .insert_header("Content-Length", total_size.to_string()), + ResponseTemplate::new(206) + .set_body_bytes(slice_body.clone()) + .insert_header("Content-Range", format!("bytes 2048-3071/{total_size}")) + .insert_header("Content-Length", "1024"), ) .expect(1) .mount(&mock_server) @@ -1155,17 +1148,21 @@ async fn test_head_without_accept_ranges_stores_length_without_range_support() { "Content-Length alone must not prove range support" ); - let range = proxy_slice(&cache, Some("bytes=0-511"), &url, &provider_headers) + let range = proxy_slice(&cache, Some("bytes=2304-2559"), &url, &provider_headers) .await .unwrap(); - assert_eq!(range.status(), StatusCode::OK); + assert_eq!(range.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!( + range.headers().get("Content-Range").unwrap(), + "bytes 2304-2559/4096" + ); assert_eq!( range.headers().get("X-Cache-Status").unwrap(), - CacheStatus::Bypass.as_str() + CacheStatus::Miss.as_str() ); assert_eq!( range.into_body().collect().await.unwrap().to_bytes(), - full_body + slice_body.slice(256..512) ); } @@ -2369,8 +2366,13 @@ async fn test_suffix_range_with_head_length_bypasses_when_origin_ignores_aligned Mock::given(method("GET")) .and(path("/suffix-no-ranges.bin")) .and(header("Range", "bytes=3072-4095")) - .respond_with(ResponseTemplate::new(500)) - .expect(0) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(full_body.clone()) + .insert_header("Content-Length", total_size.to_string()) + .insert_header("Content-Type", "application/octet-stream"), + ) + .expect(1) .mount(&mock_server) .await; @@ -2383,7 +2385,7 @@ async fn test_suffix_range_with_head_length_bypasses_when_origin_ignores_aligned .insert_header("Content-Length", total_size.to_string()) .insert_header("Content-Type", "application/octet-stream"), ) - .expect(1) + .expect(0) .mount(&mock_server) .await;