From 5cd5d2f9fff4611bfa48cf826f9bf4d37504ca90 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Tue, 26 May 2026 00:00:57 -0400 Subject: [PATCH] reduce seek buffering in range cache --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/ProgressiveHTTPRangeCache.swift | 45 ++++- Tests/StreamResolverTests.swift | 23 +++ .../2026-05-25-vlc-local-range-cache.html | 160 +++++++++++++++++- 5 files changed, 220 insertions(+), 10 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 22e71d0..4085860 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -42,3 +42,4 @@ {"id":"int-e339ed64","kind":"field_change","created_at":"2026-05-25T20:22:40.999137Z","actor":"dirtydishes","issue_id":"dreamio-dsp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"implemented local native stream cache proxy with range cache tests and successful simulator build"}} {"id":"int-79713eba","kind":"field_change","created_at":"2026-05-25T21:55:32.323229Z","actor":"dirtydishes","issue_id":"dreamio-6bv","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"handled VLC buffering follow-up by supporting HEAD probes, moving fetch work off listener queue, reducing foreground range size, and locking cache access"}} {"id":"int-b2667330","kind":"field_change","created_at":"2026-05-25T23:44:07.439593Z","actor":"dirtydishes","issue_id":"dreamio-9gw","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Capped local range-cache responses to 1 MB chunks, trimmed cached overlap windows, added focused tests, and confirmed the iOS simulator build succeeds."}} +{"id":"int-6ca684f7","kind":"field_change","created_at":"2026-05-26T04:00:46.072019Z","actor":"dirtydishes","issue_id":"dreamio-42s","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed seek-time range-cache prefetching to prioritize the post-seek byte offset and avoid cancelling active prefetch work inside the same window; added focused coverage and validated with StreamResolverTests plus xcodebuild."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 808b3f4..370b1cc 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,6 @@ {"_type":"issue","id":"dreamio-mun","title":"fix vlc cache loopback port startup","description":"Device logs showed local-cache playback opening http://127.0.0.1:0, because the NWListener ephemeral port was read before the listener reached ready. Wait for the real assigned port before returning the local cache URL.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:32:41Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:33:15Z","started_at":"2026-05-25T22:33:14Z","closed_at":"2026-05-25T22:33:15Z","close_reason":"Wait for NWListener ready state before returning the local cache URL; verified tests and simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-42s","title":"Reduce VLC range-cache buffering after seeks","description":"Logs show repeated local-cache misses and cancelled prefetch tasks after VLC jumps backward, causing buffering while the cache restarts speculative requests instead of preserving useful adjacent downloads.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T03:58:03Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:00:46Z","started_at":"2026-05-26T03:58:10Z","closed_at":"2026-05-26T04:00:46Z","close_reason":"Fixed seek-time range-cache prefetching to prioritize the post-seek byte offset and avoid cancelling active prefetch work inside the same window; added focused coverage and validated with StreamResolverTests plus xcodebuild.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-9gw","title":"Cap VLC local range cache memory","description":"Playback can be killed for memory when VLC asks the loopback cache for a very large byte range. The local range cache should answer with bounded partial ranges and trim cached segments to the active window.","status":"closed","priority":1,"issue_type":"bug","owner":"dishes@dpdrm.com","created_at":"2026-05-25T23:38:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:44:07Z","closed_at":"2026-05-25T23:44:07Z","close_reason":"Capped local range-cache responses to 1 MB chunks, trimmed cached overlap windows, added focused tests, and confirmed the iOS simulator build succeeds.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4t0","title":"Fix native external subtitle overlay fallback","description":"Parsed external subtitles are discovered but MobileVLCKit may report no imported subtitle tracks. Make Dreamio's parsed subtitle overlay the reliable fallback and add parser/overlay coverage.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T23:23:15Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:28:44Z","started_at":"2026-05-25T23:23:18Z","closed_at":"2026-05-25T23:28:44Z","close_reason":"Implemented parsed external subtitle overlay fallback, parser extraction, focused parser tests, and simulator build validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-8l9","title":"Fix native external subtitle overlay fallback","description":"External subtitles are parsed and listed, but MobileVLCKit can report no imported subtitle tracks. Make parsed external subtitles the reliable overlay fallback, keep VLC import attempts optional, and add focused parser/cue tests.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T23:13:35Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:17:40Z","started_at":"2026-05-25T23:13:49Z","closed_at":"2026-05-25T23:17:40Z","close_reason":"Implemented native parsed external subtitle overlay fallback, added SRT parser/cue tests, and validated with parser tests plus iOS Simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/ProgressiveHTTPRangeCache.swift b/Dreamio/ProgressiveHTTPRangeCache.swift index ad514ed..73b7065 100644 --- a/Dreamio/ProgressiveHTTPRangeCache.swift +++ b/Dreamio/ProgressiveHTTPRangeCache.swift @@ -16,6 +16,10 @@ struct HTTPByteRange: Equatable { func merged(with other: HTTPByteRange) -> HTTPByteRange { HTTPByteRange(start: min(start, other.start), end: max(end, other.end)) } + + func contains(_ offset: Int64) -> Bool { + start <= offset && offset <= end + } } struct HTTPContentRange: Equatable { @@ -254,6 +258,7 @@ final class ProgressiveHTTPRangeCacheSession { private let prefetchChunkSize: Int64 = 1_048_576 private let responseChunkSize: Int64 = 1_048_576 private var prefetchTask: Task? + private var activePrefetchWindow: HTTPByteRange? init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) { self.fetcher = fetcher @@ -288,10 +293,16 @@ final class ProgressiveHTTPRangeCacheSession { } func prefetch(aroundByteOffset offset: Int64) { + if activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false { + return + } + prefetchTask?.cancel() let window = targetWindow(aroundByteOffset: offset) + activePrefetchWindow = window store.evict(keeping: window) guard !store.hasData(for: window) else { + activePrefetchWindow = nil return } @@ -299,9 +310,10 @@ final class ProgressiveHTTPRangeCacheSession { guard let self else { return } - var cursor = window.start - while cursor <= window.end, !Task.isCancelled { - let chunk = HTTPByteRange(start: cursor, end: min(window.end, cursor + prefetchChunkSize - 1)) + for chunk in self.prefetchChunks(in: window, preferredOffset: offset) { + guard !Task.isCancelled else { + return + } if !store.hasData(for: chunk) { do { let data = try await fetcher.fetch(range: chunk) @@ -310,14 +322,17 @@ final class ProgressiveHTTPRangeCacheSession { print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)") #endif } catch { + if Task.isCancelled { + return + } #if DEBUG print("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)") #endif return } } - cursor = chunk.end + 1 } + self.activePrefetchWindow = nil } } @@ -333,6 +348,28 @@ final class ProgressiveHTTPRangeCacheSession { return clamp(HTTPByteRange(start: offset - behind, end: offset + ahead)) } + func prefetchChunks(in window: HTTPByteRange, preferredOffset offset: Int64) -> [HTTPByteRange] { + let boundedOffset = max(window.start, min(window.end, offset)) + let preferredStart = window.start + ((boundedOffset - window.start) / prefetchChunkSize) * prefetchChunkSize + var chunks: [HTTPByteRange] = [] + + var cursor = preferredStart + while cursor <= window.end { + let chunk = HTTPByteRange(start: cursor, end: min(window.end, cursor + prefetchChunkSize - 1)) + chunks.append(chunk) + cursor = chunk.end + 1 + } + + cursor = window.start + while cursor < preferredStart { + let chunk = HTTPByteRange(start: cursor, end: min(preferredStart - 1, cursor + prefetchChunkSize - 1)) + chunks.append(chunk) + cursor = chunk.end + 1 + } + + return chunks + } + private func estimatedBytesPerSecond() -> Int64 { let duration = durationProvider() guard duration > 1 else { diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index ec7038d..9a390ec 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -36,6 +36,7 @@ struct StreamResolverTests { testSparseRangeStoreEvictsOutsideWindow() testSparseRangeStoreTrimsOverlappingWindow() testRangeCacheSessionCapsResponseRange() + testRangeCachePrefetchPrioritizesSeekOffset() await testRangeProbeFallsBackWhenServerIgnoresRange() await testRangeFetcherPreservesHeaders() print("StreamResolverTests passed") @@ -344,6 +345,28 @@ struct StreamResolverTests { assertEqual(responseRange, HTTPByteRange(start: 0, end: 1_048_575)) } + private static func testRangeCachePrefetchPrioritizesSeekOffset() { + let session = ProgressiveHTTPRangeCacheSession( + fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]), + contentLength: 20_000_000, + durationProvider: { 0 } + ) + + let chunks = session.prefetchChunks( + in: HTTPByteRange(start: 0, end: 4_194_303), + preferredOffset: 2_200_000 + ) + + assertEqual(chunks.prefix(2).map { $0 }, [ + HTTPByteRange(start: 2_097_152, end: 3_145_727), + HTTPByteRange(start: 3_145_728, end: 4_194_303) + ]) + assertEqual(chunks.suffix(2).map { $0 }, [ + HTTPByteRange(start: 0, end: 1_048_575), + HTTPByteRange(start: 1_048_576, end: 2_097_151) + ]) + } + private static func testRangeProbeFallsBackWhenServerIgnoresRange() async { MockURLProtocol.handler = { request in if request.httpMethod == "HEAD" { diff --git a/docs/turns/2026-05-25-vlc-local-range-cache.html b/docs/turns/2026-05-25-vlc-local-range-cache.html index 34a1db7..ae398c9 100644 --- a/docs/turns/2026-05-25-vlc-local-range-cache.html +++ b/docs/turns/2026-05-25-vlc-local-range-cache.html @@ -87,7 +87,7 @@
Tests/StreamResolverTests.swift
+12
320 unmodified lines
319
320
320 unmodified lines
assertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)])
}
320 unmodified lines
321
322
323
324
325
326
327
328
329
330
331
332
333
334
320 unmodified lines
assertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)])
}
private static func testSparseRangeStoreTrimsOverlappingWindow() {
let store = SparseHTTPByteRangeStore()
store.insert(data: Data([0, 1, 2, 3, 4, 5]), at: 0)
store.evict(keeping: HTTPByteRange(start: 2, end: 4))
assertEqual(store.cachedRanges, [HTTPByteRange(start: 2, end: 4)])
}
private static func testRangeCacheSessionCapsResponseRange() {
let session = ProgressiveHTTPRangeCacheSession(fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]), contentLength: 711_080_522, durationProvider: { 0 })
assertEqual(session.responseRange(for: HTTPByteRange(start: 0, end: 711_080_521)), HTTPByteRange(start: 0, end: 1_048_575))
}

Related issues or PRs

Related to Beads issue dreamio-9gw and branch lavender/vlc-local-range-cache.

+

New Changes as of 2026-05-25 23:59 EDT

Summary of changes

Adjusted range-cache prefetching for seek-heavy playback so the cache prioritizes chunks around the post-seek byte offset and avoids cancelling useful in-flight prefetch work when the requested offset remains inside the active window.

Why this change was made

Runtime logs showed VLC entering buffering after a seek while Dreamio repeatedly reported cache misses and cancelled prefetches. The prefetcher was warming from the back edge of a broad window, which could spend bandwidth behind the seek target before fetching the bytes VLC needed next.

Code diffs

Rendered with @pierre/diffs/ssr. The diffs below cover the seek-prefetch changes and focused regression coverage.

Dreamio/ProgressiveHTTPRangeCache.swift

Dreamio/ProgressiveHTTPRangeCache.swift
-4+41
15 unmodified lines
16
17
18
19
20
21
232 unmodified lines
254
255
256
257
258
259
28 unmodified lines
288
289
290
291
292
293
294
295
296
297
1 unmodified line
299
300
301
302
303
304
305
306
307
2 unmodified lines
310
311
312
313
314
315
316
317
318
319
320
321
322
323
9 unmodified lines
333
334
335
336
337
338
15 unmodified lines
func merged(with other: HTTPByteRange) -> HTTPByteRange {
HTTPByteRange(start: min(start, other.start), end: max(end, other.end))
}
}
+
struct HTTPContentRange: Equatable {
232 unmodified lines
private let prefetchChunkSize: Int64 = 1_048_576
private let responseChunkSize: Int64 = 1_048_576
private var prefetchTask: Task<Void, Never>?
+
init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {
self.fetcher = fetcher
28 unmodified lines
}
+
func prefetch(aroundByteOffset offset: Int64) {
prefetchTask?.cancel()
let window = targetWindow(aroundByteOffset: offset)
store.evict(keeping: window)
guard !store.hasData(for: window) else {
return
}
+
1 unmodified line
guard let self else {
return
}
var cursor = window.start
while cursor <= window.end, !Task.isCancelled {
let chunk = HTTPByteRange(start: cursor, end: min(window.end, cursor + prefetchChunkSize - 1))
if !store.hasData(for: chunk) {
do {
let data = try await fetcher.fetch(range: chunk)
2 unmodified lines
print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")
#endif
} catch {
#if DEBUG
print("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)")
#endif
return
}
}
cursor = chunk.end + 1
}
}
}
+
9 unmodified lines
return clamp(HTTPByteRange(start: offset - behind, end: offset + ahead))
}
+
private func estimatedBytesPerSecond() -> Int64 {
let duration = durationProvider()
guard duration > 1 else {
15 unmodified lines
16
17
18
19
20
21
22
23
24
25
232 unmodified lines
258
259
260
261
262
263
264
28 unmodified lines
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
1 unmodified line
310
311
312
313
314
315
316
317
318
319
2 unmodified lines
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
9 unmodified lines
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
15 unmodified lines
func merged(with other: HTTPByteRange) -> HTTPByteRange {
HTTPByteRange(start: min(start, other.start), end: max(end, other.end))
}
+
func contains(_ offset: Int64) -> Bool {
start <= offset && offset <= end
}
}
+
struct HTTPContentRange: Equatable {
232 unmodified lines
private let prefetchChunkSize: Int64 = 1_048_576
private let responseChunkSize: Int64 = 1_048_576
private var prefetchTask: Task<Void, Never>?
private var activePrefetchWindow: HTTPByteRange?
+
init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {
self.fetcher = fetcher
28 unmodified lines
}
+
func prefetch(aroundByteOffset offset: Int64) {
if activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false {
return
}
+
prefetchTask?.cancel()
let window = targetWindow(aroundByteOffset: offset)
activePrefetchWindow = window
store.evict(keeping: window)
guard !store.hasData(for: window) else {
activePrefetchWindow = nil
return
}
+
1 unmodified line
guard let self else {
return
}
for chunk in self.prefetchChunks(in: window, preferredOffset: offset) {
guard !Task.isCancelled else {
return
}
if !store.hasData(for: chunk) {
do {
let data = try await fetcher.fetch(range: chunk)
2 unmodified lines
print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")
#endif
} catch {
if Task.isCancelled {
return
}
#if DEBUG
print("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)")
#endif
return
}
}
}
self.activePrefetchWindow = nil
}
}
+
9 unmodified lines
return clamp(HTTPByteRange(start: offset - behind, end: offset + ahead))
}
+
func prefetchChunks(in window: HTTPByteRange, preferredOffset offset: Int64) -> [HTTPByteRange] {
let boundedOffset = max(window.start, min(window.end, offset))
let preferredStart = window.start + ((boundedOffset - window.start) / prefetchChunkSize) * prefetchChunkSize
var chunks: [HTTPByteRange] = []
+
var cursor = preferredStart
while cursor <= window.end {
let chunk = HTTPByteRange(start: cursor, end: min(window.end, cursor + prefetchChunkSize - 1))
chunks.append(chunk)
cursor = chunk.end + 1
}
+
cursor = window.start
while cursor < preferredStart {
let chunk = HTTPByteRange(start: cursor, end: min(preferredStart - 1, cursor + prefetchChunkSize - 1))
chunks.append(chunk)
cursor = chunk.end + 1
}
+
return chunks
}
+
private func estimatedBytesPerSecond() -> Int64 {
let duration = durationProvider()
guard duration > 1 else {

Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+23
35 unmodified lines
36
37
38
39
40
41
302 unmodified lines
344
345
346
347
348
349
35 unmodified lines
testSparseRangeStoreEvictsOutsideWindow()
testSparseRangeStoreTrimsOverlappingWindow()
testRangeCacheSessionCapsResponseRange()
await testRangeProbeFallsBackWhenServerIgnoresRange()
await testRangeFetcherPreservesHeaders()
print("StreamResolverTests passed")
302 unmodified lines
assertEqual(responseRange, HTTPByteRange(start: 0, end: 1_048_575))
}
+
private static func testRangeProbeFallsBackWhenServerIgnoresRange() async {
MockURLProtocol.handler = { request in
if request.httpMethod == "HEAD" {
35 unmodified lines
36
37
38
39
40
41
42
302 unmodified lines
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
35 unmodified lines
testSparseRangeStoreEvictsOutsideWindow()
testSparseRangeStoreTrimsOverlappingWindow()
testRangeCacheSessionCapsResponseRange()
testRangeCachePrefetchPrioritizesSeekOffset()
await testRangeProbeFallsBackWhenServerIgnoresRange()
await testRangeFetcherPreservesHeaders()
print("StreamResolverTests passed")
302 unmodified lines
assertEqual(responseRange, HTTPByteRange(start: 0, end: 1_048_575))
}
+
private static func testRangeCachePrefetchPrioritizesSeekOffset() {
let session = ProgressiveHTTPRangeCacheSession(
fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),
contentLength: 20_000_000,
durationProvider: { 0 }
)
+
let chunks = session.prefetchChunks(
in: HTTPByteRange(start: 0, end: 4_194_303),
preferredOffset: 2_200_000
)
+
assertEqual(chunks.prefix(2).map { $0 }, [
HTTPByteRange(start: 2_097_152, end: 3_145_727),
HTTPByteRange(start: 3_145_728, end: 4_194_303)
])
assertEqual(chunks.suffix(2).map { $0 }, [
HTTPByteRange(start: 0, end: 1_048_575),
HTTPByteRange(start: 1_048_576, end: 2_097_151)
])
}
+
private static func testRangeProbeFallsBackWhenServerIgnoresRange() async {
MockURLProtocol.handler = { request in
if request.httpMethod == "HEAD" {

Related issues or PRs

Related to Beads issue dreamio-42s and branch lavender/vlc-local-range-cache.