2026-05-26

# VLC Seek Range Cache Priming

Dreamio now primes local VLC range-cache seeks from stable 1 MB boundaries behind the estimated target, retains useful nearby bytes under a bounded budget, and logs foreground misses with more precision.

## Summary

Reduced post-seek buffering risk in native VLC playback by making seek and jump prefetch conservative behind the target instead of only racing ahead from the estimated byte offset.

## Changes Made

- Added seek-specific prefetch through `prefetchForSeek`, with a 4 MB backward prime window and user-initiated task priority for the first chunks.
- Aligned prefetch chunks to global 1 MB boundaries so repeated VLC requests reuse stable cached ranges.
- Replaced prefetch-window eviction with a 64 MB bounded cache budget that preserves active playback, recent seek, header, and tail/index ranges.
- Changed foreground miss handling to fetch only missing subranges and log `uncached` versus `partial-miss`.
- Routed VLC delegate state handling onto the main actor before reading player state or firing UI callbacks.
- Added tests for the observed VLC request pattern, global chunk alignment, and budget retention.

## Context

A 15-second skip estimated byte `213615760`, while VLC’s first real read started at `212942432`. The old prefetch overlapped the request but missed its front edge, then eviction could discard ranges that were still useful during the next VLC probes.

## Important Implementation Details

- The observed request and estimated seek offset both fall inside global chunk `212860928-213909503`, so seek priming now warms that chunk before later ahead chunks.
- Normal prefetch still prefers the playhead area, while seek prefetch starts at the backward-biased window start.
- Cache eviction removes unprotected segments farthest from protected areas until the byte budget is met; protected ranges are not trimmed simply because a new prefetch window appears.
- Foreground reads cancel or reprioritize normal prefetch when VLC asks outside the expected area.

## Relevant Diff Snippets

Rendered with `@pierre/diffs/ssr`.

### Dreamio/ProgressiveHTTPRangeCache.swift

Dreamio/ProgressiveHTTPRangeCache.swift
-15+174

    72 unmodified lines73747576777838 unmodified lines11711811912012112212 unmodified lines135136137138139140116 unmodified lines2572582592602612622632642652662672682692702 unmodified lines2732742752762772781 unmodified line2802812822832842852862872882892902912922935 unmodified lines2993003013023033043053063073083093103113123133143153163173183193203213223233243253263273281 unmodified line33033133233333433537 unmodified lines3733743753763773783793803813823833843853863873 unmodified lines3913923933943953963973 unmodified lines40140240340440540672 unmodified lines        }    }
        func insert(data: Data, at start: Int64) {        guard !data.isEmpty else {            return38 unmodified lines        data(for: range) != nil    }
        func evict(keeping window: HTTPByteRange) {        lock.withLock {            segments = segments.compactMap { segment in12 unmodified lines        }    }
        private func mergeSegments() {        guard !segments.isEmpty else {            return116 unmodified lines    let durationProvider: () -> TimeInterval    private let prefetchChunkSize: Int64 = 1_048_576    private let responseChunkSize: Int64 = 1_048_576    private var prefetchTask: Task<Void, Never>?    private var activePrefetchWindow: HTTPByteRange?    private var activePrefetchPreferredOffset: Int64?
        init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {        self.fetcher = fetcher        self.contentLength = contentLength        self.durationProvider = durationProvider    }
        deinit {2 unmodified lines
        func data(for requestedRange: HTTPByteRange) async throws -> Data {        let bounded = clamp(requestedRange)        if let data = store.data(for: bounded) {#if DEBUG            print("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)")1 unmodified line            return data        }
    #if DEBUG        print("[DreamioRangeCache] cache=miss range=\(bounded.start)-\(bounded.end)")#endif        cancelPrefetchIfNeeded(forForegroundRange: bounded)        let data = try await fetcher.fetch(range: bounded)        store.insert(data: data, at: bounded.start)        prefetch(aroundByteOffset: bounded.end + 1, forceRestart: true)        return store.data(for: bounded) ?? data    }
        func responseRange(for requestedRange: HTTPByteRange) -> HTTPByteRange {5 unmodified lines    }
        func prefetch(aroundByteOffset offset: Int64) {        prefetch(aroundByteOffset: offset, forceRestart: false)    }
        func prefetch(aroundByteOffset offset: Int64, forceRestart: Bool) {        if !forceRestart, activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false {            return        }
            prefetchTask?.cancel()        let window = targetWindow(aroundByteOffset: offset)        activePrefetchWindow = window        activePrefetchPreferredOffset = offset        store.evict(keeping: window)        guard !store.hasData(for: window) else {            activePrefetchWindow = nil            activePrefetchPreferredOffset = nil            return        }
            prefetchTask = Task { [weak self] in            guard let self else {                return            }            for chunk in self.prefetchChunks(in: window, preferredOffset: offset) {                guard !Task.isCancelled else {                    return                }1 unmodified line                    do {                        let data = try await fetcher.fetch(range: chunk)                        store.insert(data: data, at: chunk.start)#if DEBUG                        print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")#endif37 unmodified lines    }
        private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {        let bytesPerSecond = estimatedBytesPerSecond()        let behind = max(prefetchChunkSize, bytesPerSecond * 30)        let ahead = max(prefetchChunkSize * 2, bytesPerSecond * 60)        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 = preferredStart3 unmodified lines            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)3 unmodified lines        return chunks    }
        private func estimatedBytesPerSecond() -> Int64 {        let duration = durationProvider()        guard duration > 1 else {72 unmodified lines73747576777879808182838438 unmodified lines12312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415512 unmodified lines168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228116 unmodified lines3453463473483493503513523533543553563573583593603613623633643653663673682 unmodified lines3713723733743753763771 unmodified line3793803813823833843853863873883893903913923933943953965 unmodified lines4024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544551 unmodified line45745845946046146246337 unmodified lines5015025035045055065075085095105115125135145155165175185195205215225235245253 unmodified lines5295305315325335345353 unmodified lines53954054154254354454554654754854955055155255355455555655755855956056156256356456572 unmodified lines        }    }
        var cachedByteCount: Int64 {        lock.withLock {            segments.reduce(0) { $0 + Int64($1.data.count) }        }    }
        func insert(data: Data, at start: Int64) {        guard !data.isEmpty else {            return38 unmodified lines        data(for: range) != nil    }
        func missingRanges(in range: HTTPByteRange) -> [HTTPByteRange] {        lock.withLock {            var cursor = range.start            var missing: [HTTPByteRange] = []
                for segment in segments where segment.range.end >= cursor {                guard segment.range.start <= range.end else {                    break                }                if segment.range.start > cursor {                    missing.append(HTTPByteRange(start: cursor, end: min(range.end, segment.range.start - 1)))                }                if segment.range.end >= cursor {                    cursor = max(cursor, segment.range.end + 1)                }                if cursor > range.end {                    break                }            }
                if cursor <= range.end {                missing.append(HTTPByteRange(start: cursor, end: range.end))            }            return missing        }    }
        func evict(keeping window: HTTPByteRange) {        lock.withLock {            segments = segments.compactMap { segment in12 unmodified lines        }    }
        func evict(toByteBudget byteBudget: Int64, preserving protectedRanges: [HTTPByteRange]) -> [HTTPByteRange] {        lock.withLock {            guard byteBudget > 0 else {                let evicted = segments.map(\.range)                segments.removeAll()                return evicted            }
                var totalBytes = segments.reduce(0) { $0 + Int64($1.data.count) }            guard totalBytes > byteBudget else {                return []            }
                var evicted: [HTTPByteRange] = []            while totalBytes > byteBudget,                  let index = evictionCandidateIndex(protectedRanges: protectedRanges) {                let removed = segments.remove(at: index)                totalBytes -= Int64(removed.data.count)                evicted.append(removed.range)            }            return evicted        }    }
        private func evictionCandidateIndex(protectedRanges: [HTTPByteRange]) -> Int? {        var bestIndex: Int?        var bestDistance: Int64 = .min
            for (index, segment) in segments.enumerated() {            if protectedRanges.contains(where: { $0.overlapsOrTouches(segment.range) }) {                continue            }
                let distance = protectedRanges                .map { rangeDistance(from: segment.range, to: $0) }                .min() ?? segment.range.start            if distance > bestDistance {                bestDistance = distance                bestIndex = index            }        }
            return bestIndex    }
        private func rangeDistance(from range: HTTPByteRange, to protectedRange: HTTPByteRange) -> Int64 {        if range.overlapsOrTouches(protectedRange) {            return 0        }        if range.end < protectedRange.start {            return protectedRange.start - range.end        }        return range.start - protectedRange.end    }
        private func mergeSegments() {        guard !segments.isEmpty else {            return116 unmodified lines    let durationProvider: () -> TimeInterval    private let prefetchChunkSize: Int64 = 1_048_576    private let responseChunkSize: Int64 = 1_048_576    private let seekPrimeBehindBytes: Int64 = 4 * 1_048_576    private let cacheByteBudget: Int64    private var prefetchTask: Task<Void, Never>?    private var activePrefetchWindow: HTTPByteRange?    private var activePrefetchPreferredOffset: Int64?    private var recentSeekRange: HTTPByteRange?    private var recentForegroundRange: HTTPByteRange?
        init(        fetcher: HTTPRangeRemoteFetcher,        contentLength: Int64,        durationProvider: @escaping () -> TimeInterval,        cacheByteBudget: Int64 = 64 * 1_048_576    ) {        self.fetcher = fetcher        self.contentLength = contentLength        self.durationProvider = durationProvider        self.cacheByteBudget = cacheByteBudget    }
        deinit {2 unmodified lines
        func data(for requestedRange: HTTPByteRange) async throws -> Data {        let bounded = clamp(requestedRange)        recentForegroundRange = bounded        if let data = store.data(for: bounded) {#if DEBUG            print("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)")1 unmodified line            return data        }
            let missingRanges = store.missingRanges(in: bounded)#if DEBUG        let missKind = missingRanges.count == 1 && missingRanges[0] == bounded ? "uncached" : "partial-miss"        print("[DreamioRangeCache] cache=\(missKind) range=\(bounded.start)-\(bounded.end) missing=\(missingRanges.map { "\($0.start)-\($0.end)" }.joined(separator: ","))")#endif        cancelPrefetchIfNeeded(forForegroundRange: bounded)        for missingRange in missingRanges {            let data = try await fetcher.fetch(range: missingRange)            store.insert(data: data, at: missingRange.start)        }        prefetch(aroundByteOffset: bounded.end + 1, forceRestart: true)        return store.data(for: bounded) ?? Data()    }
        func responseRange(for requestedRange: HTTPByteRange) -> HTTPByteRange {5 unmodified lines    }
        func prefetch(aroundByteOffset offset: Int64) {        prefetch(aroundByteOffset: offset, forceRestart: false, startsAtWindowStart: false)    }
        func prefetchForSeek(aroundByteOffset offset: Int64) {        let window = seekPrimeWindow(aroundByteOffset: offset)        recentSeekRange = window        prefetch(            aroundByteOffset: offset,            forceRestart: true,            explicitWindow: window,            startsAtWindowStart: true        )    }
        func seekPrimeWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {        targetWindow(aroundByteOffset: offset, minimumBehind: seekPrimeBehindBytes)    }
        func prefetch(aroundByteOffset offset: Int64, forceRestart: Bool) {        prefetch(aroundByteOffset: offset, forceRestart: forceRestart, startsAtWindowStart: false)    }
        private func prefetch(        aroundByteOffset offset: Int64,        forceRestart: Bool,        explicitWindow: HTTPByteRange? = nil,        startsAtWindowStart: Bool    ) {        if !forceRestart, activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false {            return        }
            prefetchTask?.cancel()        let window = explicitWindow ?? targetWindow(aroundByteOffset: offset)        activePrefetchWindow = window        activePrefetchPreferredOffset = offset        evictOverBudget(protecting: window)        guard !store.hasData(for: window) else {            activePrefetchWindow = nil            activePrefetchPreferredOffset = nil            return        }
            prefetchTask = Task(priority: startsAtWindowStart ? .userInitiated : .utility) { [weak self] in            guard let self else {                return            }            for chunk in self.prefetchChunks(in: window, preferredOffset: offset, startsAtWindowStart: startsAtWindowStart) {                guard !Task.isCancelled else {                    return                }1 unmodified line                    do {                        let data = try await fetcher.fetch(range: chunk)                        store.insert(data: data, at: chunk.start)                        evictOverBudget(protecting: window)#if DEBUG                        print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")#endif37 unmodified lines    }
        private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {        targetWindow(aroundByteOffset: offset, minimumBehind: prefetchChunkSize)    }
        private func targetWindow(aroundByteOffset offset: Int64, minimumBehind: Int64) -> HTTPByteRange {        let bytesPerSecond = estimatedBytesPerSecond()        let behind = max(minimumBehind, bytesPerSecond * 30)        let ahead = max(prefetchChunkSize * 2, bytesPerSecond * 60)        let raw = clamp(HTTPByteRange(start: offset - behind, end: offset + ahead))        return HTTPByteRange(start: alignedChunkStart(for: raw.start), end: alignedChunkEnd(for: raw.end))    }
        func prefetchChunks(in window: HTTPByteRange, preferredOffset offset: Int64) -> [HTTPByteRange] {        prefetchChunks(in: window, preferredOffset: offset, startsAtWindowStart: false)    }
        func prefetchChunks(in window: HTTPByteRange, preferredOffset offset: Int64, startsAtWindowStart: Bool) -> [HTTPByteRange] {        let boundedOffset = max(window.start, min(window.end, offset))        let windowStart = alignedChunkStart(for: window.start)        let preferredStart = startsAtWindowStart ? windowStart : alignedChunkStart(for: boundedOffset)        var chunks: [HTTPByteRange] = []
            var cursor = preferredStart3 unmodified lines            cursor = chunk.end + 1        }
            cursor = windowStart        while cursor < preferredStart {            let chunk = HTTPByteRange(start: cursor, end: min(preferredStart - 1, cursor + prefetchChunkSize - 1))            chunks.append(chunk)3 unmodified lines        return chunks    }
        private func evictOverBudget(protecting range: HTTPByteRange) {        let headerRange = HTTPByteRange(start: 0, end: min(contentLength - 1, prefetchChunkSize - 1))        let tailStart = max(0, contentLength - (4 * prefetchChunkSize))        let tailRange = HTTPByteRange(start: tailStart, end: contentLength - 1)        let protectedRanges = [range, recentSeekRange, recentForegroundRange, activePrefetchWindow, headerRange, tailRange].compactMap { $0 }        let evicted = store.evict(toByteBudget: cacheByteBudget, preserving: protectedRanges)#if DEBUG        if !evicted.isEmpty {            print("[DreamioRangeCache] evicted reason=budget ranges=\(evicted.map { "\($0.start)-\($0.end)" }.joined(separator: ",")) protected=\(protectedRanges.map { "\($0.start)-\($0.end)" }.joined(separator: ","))")        }#endif    }
        private func alignedChunkStart(for offset: Int64) -> Int64 {        max(0, (offset / prefetchChunkSize) * prefetchChunkSize)    }
        private func alignedChunkEnd(for offset: Int64) -> Int64 {        min(contentLength - 1, alignedChunkStart(for: offset) + prefetchChunkSize - 1)    }
        private func estimatedBytesPerSecond() -> Int64 {        let duration = durationProvider()        guard duration > 1 else {

### Dreamio/VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
-2+9

    150 unmodified lines15115215315415515615713 unmodified lines171172173174175176177426 unmodified lines604605606607608609150 unmodified lines            return        }        let clamped = max(0, min(1, position))        rangeCacheSession?.prefetch(aroundByteOffset: rangeCacheSession?.byteOffset(for: clamped) ?? 0)#if DEBUG        if let byteOffset = rangeCacheSession?.byteOffset(for: clamped) {            print("[DreamioVLC] seek targetPosition=\(clamped) byteOffset=\(byteOffset) mode=local-cache")13 unmodified lines        let nextTime = max(0, min(duration, currentTime + seconds))        if duration > 0 {            let nextPosition = Float(nextTime / duration)            rangeCacheSession?.prefetch(aroundByteOffset: rangeCacheSession?.byteOffset(for: nextPosition) ?? 0)#if DEBUG            if let byteOffset = rangeCacheSession?.byteOffset(for: nextPosition) {                print("[DreamioVLC] jump seconds=\(seconds) target=\(nextTime) byteOffset=\(byteOffset) mode=local-cache")426 unmodified lines#if canImport(MobileVLCKit)extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {    func mediaPlayerStateChanged(_ aNotification: Notification) {#if DEBUG        logPlaybackStateIfNeeded(stateName(mediaPlayer.state))#endif150 unmodified lines15115215315415515615713 unmodified lines171172173174175176177426 unmodified lines604605606607608609610611612613614615616150 unmodified lines            return        }        let clamped = max(0, min(1, position))        rangeCacheSession?.prefetchForSeek(aroundByteOffset: rangeCacheSession?.byteOffset(for: clamped) ?? 0)#if DEBUG        if let byteOffset = rangeCacheSession?.byteOffset(for: clamped) {            print("[DreamioVLC] seek targetPosition=\(clamped) byteOffset=\(byteOffset) mode=local-cache")13 unmodified lines        let nextTime = max(0, min(duration, currentTime + seconds))        if duration > 0 {            let nextPosition = Float(nextTime / duration)            rangeCacheSession?.prefetchForSeek(aroundByteOffset: rangeCacheSession?.byteOffset(for: nextPosition) ?? 0)#if DEBUG            if let byteOffset = rangeCacheSession?.byteOffset(for: nextPosition) {                print("[DreamioVLC] jump seconds=\(seconds) target=\(nextTime) byteOffset=\(byteOffset) mode=local-cache")426 unmodified lines#if canImport(MobileVLCKit)extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {    func mediaPlayerStateChanged(_ aNotification: Notification) {        Task { @MainActor [weak self] in            self?.handleMediaPlayerStateChanged()        }    }
        @MainActor    private func handleMediaPlayerStateChanged() {#if DEBUG        logPlaybackStateIfNeeded(stateName(mediaPlayer.state))#endif

### Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
-2+71

    34 unmodified lines3536373839404142291 unmodified lines33433533633733833928 unmodified lines36836937037137237332 unmodified lines40640740840941041141241334 unmodified lines        testSparseRangeStoreHitPartialHitAndMiss()        testSparseRangeStoreEvictsOutsideWindow()        testSparseRangeStoreTrimsOverlappingWindow()        testRangeCacheSessionCapsResponseRange()        testRangeCachePrefetchPrioritizesSeekOffset()        await testRangeCacheForegroundMissReprioritizesPrefetch()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()291 unmodified lines        assert(store.data(for: HTTPByteRange(start: 0, end: 5)) == nil, "Expected trimmed bytes outside the window to be evicted")    }
        private static func testRangeCacheSessionCapsResponseRange() {        let session = ProgressiveHTTPRangeCacheSession(            fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),28 unmodified lines        ])    }
        private static func testRangeCacheForegroundMissReprioritizesPrefetch() async {        let queue = DispatchQueue(label: "dreamio.range-cache-test")        var requestedRanges: [String] = []32 unmodified lines        let ranges = queue.sync { requestedRanges }        assert(ranges.contains("bytes=51818977-52867552"), "Expected foreground VLC range to be fetched")        assert(ranges.contains { range in            range.hasPrefix("bytes=51936225-")        }, "Expected prefetch to restart near VLC's foreground range, got \(ranges)")        session.cancelPrefetch()        MockURLProtocol.handler = nil        try? await Task.sleep(nanoseconds: 50_000_000)34 unmodified lines3536373839404142434445291 unmodified lines33733833934034134234334434534634734834935035135235335435535635735835936036136236336428 unmodified lines39339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144232 unmodified lines47547647747847948048148234 unmodified lines        testSparseRangeStoreHitPartialHitAndMiss()        testSparseRangeStoreEvictsOutsideWindow()        testSparseRangeStoreTrimsOverlappingWindow()        testSparseRangeStoreEvictsByBudgetWhilePreservingUsefulRanges()        testRangeCacheSessionCapsResponseRange()        testRangeCachePrefetchPrioritizesSeekOffset()        testRangeCacheSeekPrimingIncludesObservedVLCStart()        testRangeCachePrefetchUsesGlobalChunkBoundaries()        await testRangeCacheForegroundMissReprioritizesPrefetch()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()291 unmodified lines        assert(store.data(for: HTTPByteRange(start: 0, end: 5)) == nil, "Expected trimmed bytes outside the window to be evicted")    }
        private static func testSparseRangeStoreEvictsByBudgetWhilePreservingUsefulRanges() {        let store = SparseHTTPByteRangeStore()
            store.insert(data: Data(repeating: 1, count: 4), at: 0)        store.insert(data: Data(repeating: 2, count: 4), at: 100)        store.insert(data: Data(repeating: 3, count: 4), at: 200)
            let evicted = store.evict(            toByteBudget: 8,            preserving: [                HTTPByteRange(start: 0, end: 3),                HTTPByteRange(start: 190, end: 210)            ]        )
            assertEqual(evicted, [HTTPByteRange(start: 100, end: 103)])        assertEqual(store.cachedRanges, [            HTTPByteRange(start: 0, end: 3),            HTTPByteRange(start: 200, end: 203)        ])    }
        private static func testRangeCacheSessionCapsResponseRange() {        let session = ProgressiveHTTPRangeCacheSession(            fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),28 unmodified lines        ])    }
        private static func testRangeCacheSeekPrimingIncludesObservedVLCStart() {        let session = ProgressiveHTTPRangeCacheSession(            fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),            contentLength: 711_080_522,            durationProvider: { 0 }        )
            let estimatedOffset: Int64 = 213_615_760        let firstVLCRequest = HTTPByteRange(start: 212_942_432, end: 213_991_007)        let window = session.seekPrimeWindow(aroundByteOffset: estimatedOffset)        let chunks = session.prefetchChunks(            in: window,            preferredOffset: estimatedOffset,            startsAtWindowStart: true        )
            let chunkContainingVLCStart = chunks.firstIndex { $0.contains(firstVLCRequest.start) }        let chunkContainingEstimatedOffset = chunks.firstIndex { $0.contains(estimatedOffset) }
            assert(chunkContainingVLCStart != nil, "Expected seek priming to include VLC's first request start")        assert(chunkContainingEstimatedOffset != nil, "Expected seek priming to include the estimated offset")        assert(            chunkContainingVLCStart! <= chunkContainingEstimatedOffset!,            "Expected bytes behind the seek target to be primed before ahead chunks"        )        assertEqual(chunks[chunkContainingVLCStart!], HTTPByteRange(start: 212_860_928, end: 213_909_503))    }
        private static func testRangeCachePrefetchUsesGlobalChunkBoundaries() {        let session = ProgressiveHTTPRangeCacheSession(            fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),            contentLength: 711_080_522,            durationProvider: { 0 }        )
            let chunks = session.prefetchChunks(            in: HTTPByteRange(start: 213_278_260, end: 216_000_000),            preferredOffset: 213_615_760        )
            assert(chunks.allSatisfy { $0.start % 1_048_576 == 0 }, "Expected prefetch chunk starts to use stable global 1 MB boundaries: \(chunks)")        assertEqual(chunks[0], HTTPByteRange(start: 212_860_928, end: 213_909_503))    }
        private static func testRangeCacheForegroundMissReprioritizesPrefetch() async {        let queue = DispatchQueue(label: "dreamio.range-cache-test")        var requestedRanges: [String] = []32 unmodified lines        let ranges = queue.sync { requestedRanges }        assert(ranges.contains("bytes=51818977-52867552"), "Expected foreground VLC range to be fetched")        assert(ranges.contains { range in            range.hasPrefix("bytes=52428800-")        }, "Expected prefetch to restart on a global chunk boundary near VLC's foreground range, got \(ranges)")        session.cancelPrefetch()        MockURLProtocol.handler = nil        try? await Task.sleep(nanoseconds: 50_000_000)

## Expected Impact for End-Users

Short backward or forward skips in MKV/direct-file playback should resume with fewer full foreground misses, especially when VLC asks slightly before Dreamio’s estimated byte position.

## Validation

- Ran `swiftc -parse-as-library Tests/StreamResolverTests.swift Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/ProgressiveHTTPRangeCache.swift Dreamio/ExternalSubtitleTrackParser.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests`: passed.
- Ran `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build`: passed.

## Issues, Limitations, and Mitigations

- No device playback check was run in this turn, so real MobileVLCKit timing still needs confirmation on hardware.
- The cache remains memory-only. The 64 MB budget limits growth while retaining the ranges most likely to matter after seeks and MKV index probes.
- The existing MobileVLCKit/OpenGLES warning is treated as internal unless it persists after the main-actor cleanup.

## Follow-up Work

- Manually test MKV skip behavior on device and compare DEBUG logs for post-seek `hit`, `partial-miss`, and `uncached` reads.
- Consider exposing cache budget tuning if device memory pressure shows up in longer playback sessions.
- Track any persistent VLCOpenGLES2VideoView warnings separately with device logs if they continue.

## New Changes as of 2026-05-26 00:40 EDT

### Summary of changes

Adjusted the cache to follow VLC's actual foreground read area even when those reads are hits, and changed foreground partial misses to fetch stable aligned chunks synchronously.

### Why this change was made

Device logs showed a backward jump estimating byte `15936567`, while VLC continued reading from about `27165812` through the warmed cache. Because those reads were hits, prefetch stayed near the estimate until VLC reached the cache edge and began partial misses.

### Code diffs

### Dreamio/ProgressiveHTTPRangeCache.swift

Dreamio/ProgressiveHTTPRangeCache.swift
-2+32

    375 unmodified lines3763773783793803814 unmodified lines386387388389390391392393106 unmodified lines50050150250350450533 unmodified lines539540541542543544375 unmodified lines#if DEBUG            print("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)")#endif            return data        }
    4 unmodified lines#endif        cancelPrefetchIfNeeded(forForegroundRange: bounded)        for missingRange in missingRanges {            let data = try await fetcher.fetch(range: missingRange)            store.insert(data: data, at: missingRange.start)        }        prefetch(aroundByteOffset: bounded.end + 1, forceRestart: true)        return store.data(for: bounded) ?? Data()106 unmodified lines        cancelPrefetch()    }
        private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {        targetWindow(aroundByteOffset: offset, minimumBehind: prefetchChunkSize)    }33 unmodified lines        return chunks    }
        private func evictOverBudget(protecting range: HTTPByteRange) {        let headerRange = HTTPByteRange(start: 0, end: min(contentLength - 1, prefetchChunkSize - 1))        let tailStart = max(0, contentLength - (4 * prefetchChunkSize))375 unmodified lines3763773783793803813824 unmodified lines387388389390391392393394395396397398399106 unmodified lines50650750850951051151251351451551651751851952052152252333 unmodified lines557558559560561562563564565566567568569570571572573574375 unmodified lines#if DEBUG            print("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)")#endif            prefetchAheadIfForegroundMoved(to: bounded)            return data        }
    4 unmodified lines#endif        cancelPrefetchIfNeeded(forForegroundRange: bounded)        for missingRange in missingRanges {            for fetchRange in alignedChunks(covering: missingRange) where !store.hasData(for: fetchRange) {                let data = try await fetcher.fetch(range: fetchRange)                store.insert(data: data, at: fetchRange.start)#if DEBUG                print("[DreamioRangeCache] foreground fetched range=\(fetchRange.start)-\(fetchRange.end) bytes=\(data.count)")#endif            }        }        prefetch(aroundByteOffset: bounded.end + 1, forceRestart: true)        return store.data(for: bounded) ?? Data()106 unmodified lines        cancelPrefetch()    }
        private func prefetchAheadIfForegroundMoved(to range: HTTPByteRange) {        guard activePrefetchWindow?.contains(range.start) == true,              let preferredOffset = activePrefetchPreferredOffset,              abs(range.start - preferredOffset) >= responseChunkSize else {            return        }#if DEBUG        print("[DreamioRangeCache] prefetch follow-foreground from=\(preferredOffset) to=\(range.end + 1)")#endif        prefetch(aroundByteOffset: range.end + 1, forceRestart: true)    }
        private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {        targetWindow(aroundByteOffset: offset, minimumBehind: prefetchChunkSize)    }33 unmodified lines        return chunks    }
        func alignedChunks(covering range: HTTPByteRange) -> [HTTPByteRange] {        let bounded = clamp(range)        var chunks: [HTTPByteRange] = []        var cursor = alignedChunkStart(for: bounded.start)        while cursor <= bounded.end {            let chunk = HTTPByteRange(start: cursor, end: min(contentLength - 1, cursor + prefetchChunkSize - 1))            chunks.append(chunk)            cursor = chunk.end + 1        }        return chunks    }
        private func evictOverBudget(protecting range: HTTPByteRange) {        let headerRange = HTTPByteRange(start: 0, end: min(contentLength - 1, prefetchChunkSize - 1))        let tailStart = max(0, contentLength - (4 * prefetchChunkSize))

### Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
-1+59

    39 unmodified lines40414243444546390 unmodified lines43743843944044144230 unmodified lines4734744754764774784792 unmodified lines48248348448548648739 unmodified lines        testRangeCachePrefetchPrioritizesSeekOffset()        testRangeCacheSeekPrimingIncludesObservedVLCStart()        testRangeCachePrefetchUsesGlobalChunkBoundaries()        await testRangeCacheForegroundMissReprioritizesPrefetch()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()        print("StreamResolverTests passed")390 unmodified lines        assertEqual(chunks[0], HTTPByteRange(start: 212_860_928, end: 213_909_503))    }
        private static func testRangeCacheForegroundMissReprioritizesPrefetch() async {        let queue = DispatchQueue(label: "dreamio.range-cache-test")        var requestedRanges: [String] = []30 unmodified lines        try? await Task.sleep(nanoseconds: 50_000_000)
            let ranges = queue.sync { requestedRanges }        assert(ranges.contains("bytes=51818977-52867552"), "Expected foreground VLC range to be fetched")        assert(ranges.contains { range in            range.hasPrefix("bytes=52428800-")        }, "Expected prefetch to restart on a global chunk boundary near VLC's foreground range, got \(ranges)")2 unmodified lines        try? await Task.sleep(nanoseconds: 50_000_000)    }
        private static func byteRange(fromHeader header: String, contentLength: Int64) -> HTTPByteRange {        let value = header.replacingOccurrences(of: "bytes=", with: "")        let pieces = value.split(separator: "-", maxSplits: 1).map(String.init)39 unmodified lines404142434445464748390 unmodified lines43944044144244344444544644744844945045145245345445545630 unmodified lines4874884894904914924932 unmodified lines49649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454539 unmodified lines        testRangeCachePrefetchPrioritizesSeekOffset()        testRangeCacheSeekPrimingIncludesObservedVLCStart()        testRangeCachePrefetchUsesGlobalChunkBoundaries()        testRangeCacheForegroundMissFetchesAlignedChunks()        await testRangeCacheForegroundMissReprioritizesPrefetch()        await testRangeCacheHitFollowsActualPostSeekReadArea()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()        print("StreamResolverTests passed")390 unmodified lines        assertEqual(chunks[0], HTTPByteRange(start: 212_860_928, end: 213_909_503))    }
        private static func testRangeCacheForegroundMissFetchesAlignedChunks() {        let session = ProgressiveHTTPRangeCacheSession(            fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),            contentLength: 711_080_522,            durationProvider: { 0 }        )
            let chunks = session.alignedChunks(covering: HTTPByteRange(start: 48_234_649, end: 49_185_907))
            assertEqual(chunks, [HTTPByteRange(start: 48_234_496, end: 49_283_071)])    }
        private static func testRangeCacheForegroundMissReprioritizesPrefetch() async {        let queue = DispatchQueue(label: "dreamio.range-cache-test")        var requestedRanges: [String] = []30 unmodified lines        try? await Task.sleep(nanoseconds: 50_000_000)
            let ranges = queue.sync { requestedRanges }        assert(ranges.contains("bytes=51380224-52428799"), "Expected foreground VLC miss to fetch aligned cache chunks")        assert(ranges.contains { range in            range.hasPrefix("bytes=52428800-")        }, "Expected prefetch to restart on a global chunk boundary near VLC's foreground range, got \(ranges)")2 unmodified lines        try? await Task.sleep(nanoseconds: 50_000_000)    }
        private static func testRangeCacheHitFollowsActualPostSeekReadArea() async {        let queue = DispatchQueue(label: "dreamio.range-cache-hit-follow-test")        var requestedRanges: [String] = []        MockURLProtocol.handler = { request in            let range = request.value(forHTTPHeaderField: "Range") ?? ""            queue.sync {                requestedRanges.append(range)            }            let byteRange = byteRange(fromHeader: range, contentLength: 80_000_000)            let response = HTTPURLResponse(                url: request.url!,                statusCode: 206,                httpVersion: nil,                headerFields: ["Content-Range": "bytes \(byteRange.start)-\(byteRange.end)/80000000"]            )!            return (Data(repeating: 1, count: Int(byteRange.length)), response)        }
            let session = ProgressiveHTTPRangeCacheSession(            fetcher: HTTPRangeRemoteFetcher(                url: URL(string: "https://cdn.example.test/movie.mp4")!,                headers: [:],                session: mockSession()            ),            contentLength: 80_000_000,            durationProvider: { 100 }        )        defer {            session.cancelPrefetch()        }
            session.store.insert(data: Data(repeating: 7, count: 1_048_576), at: 27_165_812)        session.prefetchForSeek(aroundByteOffset: 15_936_567)        _ = try? await session.data(for: HTTPByteRange(start: 27_165_812, end: 28_214_387))        try? await Task.sleep(nanoseconds: 100_000_000)
            let ranges = queue.sync { requestedRanges }        assert(ranges.contains { range in            range.hasPrefix("bytes=27262976-")        }, "Expected a cache hit far from the seek estimate to restart prefetch near VLC's real read area, got \(ranges)")        MockURLProtocol.handler = nil        try? await Task.sleep(nanoseconds: 50_000_000)    }
        private static func byteRange(fromHeader header: String, contentLength: Int64) -> HTTPByteRange {        let value = header.replacingOccurrences(of: "bytes=", with: "")        let pieces = value.split(separator: "-", maxSplits: 1).map(String.init)

### Related issues or PRs

Beads issue `dreamio-mi1`.

## New Changes as of May 26, 2026 at 10:52 AM EDT

### Summary of changes

Updated the local HTTP range cache prefetch bookkeeping after reviewing fresh device logs from normal playback and a warmed-cache 15 second skip. Normal cold-cache misses are expected, but the warmed-cache skip showed the foreground read moving behind the active prefetch cursor while older prefetch state could still finish and mutate session state.

### Why this change was made

The cache should follow the bytes VLC is actually reading after a skip, not keep stale prefetch state alive after a newer foreground read has reoriented the cache. This update makes canceled prefetch workers stop before inserting fetched bytes after cancellation and prevents stale workers from clearing newer active prefetch state.

### Code diffs

Rendered with `@pierre/diffs/ssr`.

### Dreamio/ProgressiveHTTPRangeCache.swift

Dreamio/ProgressiveHTTPRangeCache.swift
-2+14

    349 unmodified lines35035135235335435585 unmodified lines44144244344444544615 unmodified lines46246346446546646710 unmodified lines4784794804814824831 unmodified line48548648748848949021 unmodified lines512513514515516517518519520521349 unmodified lines    private var prefetchTask: Task<Void, Never>?    private var activePrefetchWindow: HTTPByteRange?    private var activePrefetchPreferredOffset: Int64?    private var recentSeekRange: HTTPByteRange?    private var recentForegroundRange: HTTPByteRange?
    85 unmodified lines        }
            prefetchTask?.cancel()        let window = explicitWindow ?? targetWindow(aroundByteOffset: offset)        activePrefetchWindow = window        activePrefetchPreferredOffset = offset15 unmodified lines                if !store.hasData(for: chunk) {                    do {                        let data = try await fetcher.fetch(range: chunk)                        store.insert(data: data, at: chunk.start)                        evictOverBudget(protecting: window)#if DEBUG10 unmodified lines                    }                }            }            self.activePrefetchWindow = nil            self.activePrefetchPreferredOffset = nil        }1 unmodified line
        func cancelPrefetch() {        prefetchTask?.cancel()        activePrefetchWindow = nil        activePrefetchPreferredOffset = nil    }21 unmodified lines              abs(range.start - preferredOffset) >= responseChunkSize else {            return        }#if DEBUG        print("[DreamioRangeCache] prefetch follow-foreground from=\(preferredOffset) to=\(range.end + 1)")#endif        prefetch(aroundByteOffset: range.end + 1, forceRestart: true)    }
        private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {349 unmodified lines35035135235335435535685 unmodified lines44244344444544644744844915 unmodified lines46546646746846947047147247310 unmodified lines4844854864874884894904914921 unmodified line49449549649749849950021 unmodified lines522523524525526527528529530531532533349 unmodified lines    private var prefetchTask: Task<Void, Never>?    private var activePrefetchWindow: HTTPByteRange?    private var activePrefetchPreferredOffset: Int64?    private var prefetchGeneration: UInt64 = 0    private var recentSeekRange: HTTPByteRange?    private var recentForegroundRange: HTTPByteRange?
    85 unmodified lines        }
            prefetchTask?.cancel()        prefetchGeneration += 1        let generation = prefetchGeneration        let window = explicitWindow ?? targetWindow(aroundByteOffset: offset)        activePrefetchWindow = window        activePrefetchPreferredOffset = offset15 unmodified lines                if !store.hasData(for: chunk) {                    do {                        let data = try await fetcher.fetch(range: chunk)                        guard !Task.isCancelled else {                            return                        }                        store.insert(data: data, at: chunk.start)                        evictOverBudget(protecting: window)#if DEBUG10 unmodified lines                    }                }            }            guard self.prefetchGeneration == generation else {                return            }            self.activePrefetchWindow = nil            self.activePrefetchPreferredOffset = nil        }1 unmodified line
        func cancelPrefetch() {        prefetchTask?.cancel()        prefetchGeneration += 1        activePrefetchWindow = nil        activePrefetchPreferredOffset = nil    }21 unmodified lines              abs(range.start - preferredOffset) >= responseChunkSize else {            return        }        let nextOffset = range.end + 1#if DEBUG        let reason = nextOffset < preferredOffset ? "reanchor-foreground" : "follow-foreground"        print("[DreamioRangeCache] prefetch \(reason) from=\(preferredOffset) to=\(nextOffset)")#endif        prefetch(aroundByteOffset: nextOffset, forceRestart: true)    }
        private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {

### Related issues or PRs

Beads issue `dreamio-2hw`.