From 5e2590142800e74ae9f07408a8d8821d521fb258 Mon Sep 17 00:00:00 2001 From: blurbery Date: Thu, 13 Aug 2026 23:14:09 +1000 Subject: [PATCH 1/2] bound SiloPlayer speculative startup readahead Keep the loopback source window at most 64 MiB ahead of real consumer demand until first frame, then restore the normal full cache high-water policy. This prevents head/tail/midpoint MKV probes from competing with a cache-filling speculative stream and evicting startup bytes. Blocked demand always overrides the limit, so this is not a download quota and cannot strand the reader. Adds focused policy coverage. Syntax parsing passes; full Xcode/tvOS compilation and physical Apple TV validation remain required because no reachable Xcode builder is available in this environment. --- .../PlaybackOriginStreamPolicyTests.swift | 32 +++++++++++++++ .../Player/PlaybackSourceOriginStream.swift | 8 +++- .../Player/PlaybackSourcePrefetchPolicy.swift | 14 +++++++ .../Screens/Player/PlaybackSourceProxy.swift | 39 +++++++++++++++++-- .../Screens/Player/PlayerViewModel.swift | 9 ++++- 5 files changed, 96 insertions(+), 6 deletions(-) diff --git a/iosApp/Tests/PlaybackOriginStreamPolicyTests.swift b/iosApp/Tests/PlaybackOriginStreamPolicyTests.swift index 9b3cf38a..9f67595b 100644 --- a/iosApp/Tests/PlaybackOriginStreamPolicyTests.swift +++ b/iosApp/Tests/PlaybackOriginStreamPolicyTests.swift @@ -392,6 +392,38 @@ final class PlaybackOriginStreamPolicyTests: XCTestCase { ) ) } + + func testStartupLeadLimitParksSpeculativeFillWithBudgetAvailable() { + let limit = PlaybackSourcePrefetchPolicy.loopbackStartupMaximumAheadBytes + XCTAssertTrue( + PlaybackOriginStreamPolicy.shouldPause( + writeCursor: limit, + demandMark: 0, + globalBudgetAvailable: true, + maximumAheadBytes: limit + ) + ) + XCTAssertFalse( + PlaybackOriginStreamPolicy.shouldPause( + writeCursor: limit - 1, + demandMark: 0, + globalBudgetAvailable: true, + maximumAheadBytes: limit + ) + ) + } + + func testBlockedDemandOverridesStartupLeadLimit() { + let limit = PlaybackSourcePrefetchPolicy.loopbackStartupMaximumAheadBytes + XCTAssertFalse( + PlaybackOriginStreamPolicy.shouldPause( + writeCursor: limit, + demandMark: limit, + globalBudgetAvailable: false, + maximumAheadBytes: limit + ) + ) + } } final class PlaybackOriginReconnectPolicyTests: XCTestCase { diff --git a/iosApp/iosApp/Screens/Player/PlaybackSourceOriginStream.swift b/iosApp/iosApp/Screens/Player/PlaybackSourceOriginStream.swift index 6325c5a2..61d1c1f5 100644 --- a/iosApp/iosApp/Screens/Player/PlaybackSourceOriginStream.swift +++ b/iosApp/iosApp/Screens/Player/PlaybackSourceOriginStream.swift @@ -164,9 +164,15 @@ enum PlaybackOriginStreamPolicy { static func shouldPause( writeCursor: Int64, demandMark: Int64, - globalBudgetAvailable: Bool + globalBudgetAvailable: Bool, + maximumAheadBytes: Int64? = nil ) -> Bool { if demandMark >= writeCursor { return false } + if let maximumAheadBytes, + maximumAheadBytes >= 0, + writeCursor - demandMark >= maximumAheadBytes { + return true + } return !globalBudgetAvailable } } diff --git a/iosApp/iosApp/Screens/Player/PlaybackSourcePrefetchPolicy.swift b/iosApp/iosApp/Screens/Player/PlaybackSourcePrefetchPolicy.swift index 2b788184..779ed526 100644 --- a/iosApp/iosApp/Screens/Player/PlaybackSourcePrefetchPolicy.swift +++ b/iosApp/iosApp/Screens/Player/PlaybackSourcePrefetchPolicy.swift @@ -1,6 +1,20 @@ import Foundation enum PlaybackSourcePrefetchPolicy { + /// Bound only the speculative lead while SiloPlayer is preparing its + /// first local-HLS fragment. The MKV planner performs latency-sensitive + /// head, tail, and midpoint reads before it can publish the VOD manifest. + /// Letting the unrelated sequential window fill the complete 256 MiB + /// source-cache budget during those probes can consume the WAN, evict + /// startup bytes, and make the real reader re-anchor the window. + /// + /// This is an ahead-of-demand limit, not a download or startup quota: + /// cached reads advance the demand mark every 256 KiB, and a blocked read + /// at the cursor always overrides the limit. The view model releases it + /// after first frame so steady-state playback recovers the full cache + /// budget and its normal outage runway. + static let loopbackStartupMaximumAheadBytes: Int64 = 64 * 1024 * 1024 + static func initialOffset( sourceStartTimeSeconds: Double, sourceBitrateBps: Double? diff --git a/iosApp/iosApp/Screens/Player/PlaybackSourceProxy.swift b/iosApp/iosApp/Screens/Player/PlaybackSourceProxy.swift index 42402fd1..f26f07f1 100644 --- a/iosApp/iosApp/Screens/Player/PlaybackSourceProxy.swift +++ b/iosApp/iosApp/Screens/Player/PlaybackSourceProxy.swift @@ -731,6 +731,11 @@ private final class PlaybackSourceResource { private var windowStream: PlaybackOriginStream? private var chunkFetcher: PlaybackOriginChunkFetcher? private var demandCounter: UInt64 = 0 + /// Optional startup-only lead limit for the sequential origin window. + /// Guarded by `stateLock`; nil means the normal cache high-water policy. + /// The cap follows the consumer's demand mark, so it can never strand a + /// blocked read and does not reduce the total bytes a title may consume. + private var startupPrefetchMaximumAheadBytes: Int64? /// Current playback rate (1.0 = normal). Cache drain time — and thus the /// adaptive detach grace — scales with consumption speed, not the file's /// nominal bitrate. Guarded by `stateLock`. @@ -776,7 +781,8 @@ private final class PlaybackSourceResource { outageRideThroughEnabled: Bool = PlaybackOriginOutagePolicy.rideThroughEnabled(), resumeCapable: Bool, serverAdvertisesDirectStreamResume: Bool, - originStreamClock: PlaybackOriginStreamClock + originStreamClock: PlaybackOriginStreamClock, + startupPrefetchMaximumAheadBytes: Int64? = nil ) { self.token = Self.makeToken() self.originURL = originURL @@ -789,6 +795,7 @@ private final class PlaybackSourceResource { self.resumeCapable = resumeCapable self.serverAdvertisesDirectStreamResume = serverAdvertisesDirectStreamResume self.originStreamClock = originStreamClock + self.startupPrefetchMaximumAheadBytes = startupPrefetchMaximumAheadBytes.map { max(0, $0) } } deinit { @@ -1024,6 +1031,22 @@ private final class PlaybackSourceResource { stateLock.unlock() } + /// Lift the startup-only speculative lead limit without replacing the + /// warm URLSession connection. A parked stream is nudged after the state + /// transition so it can immediately fill toward the cache high-water mark. + func releaseStartupPrefetchLimit() { + var windowToResume: PlaybackOriginStream? + stateLock.lock() + if startupPrefetchMaximumAheadBytes != nil { + startupPrefetchMaximumAheadBytes = nil + windowToResume = windowStream + } + stateLock.unlock() + guard let windowToResume else { return } + Self.logger.info("[CMP-SOURCE-CACHE] startup prefetch lead limit released") + windowToResume.resumeFillingIfNeeded() + } + private func currentPlaybackRate() -> Double { stateLock.lock() defer { stateLock.unlock() } @@ -2008,11 +2031,13 @@ private final class PlaybackSourceResource { stateLock.unlock() return false } + let maximumAheadBytes = startupPrefetchMaximumAheadBytes stateLock.unlock() return !PlaybackOriginStreamPolicy.shouldPause( writeCursor: cursor, demandMark: demandMark, - globalBudgetAvailable: cache.shouldPrefetch + globalBudgetAvailable: cache.shouldPrefetch, + maximumAheadBytes: maximumAheadBytes ) } @@ -2072,7 +2097,8 @@ final class PlaybackSourceProxy { outageRideThroughEnabled: Bool = PlaybackOriginOutagePolicy.rideThroughEnabled(), resumeCapable: Bool = false, serverAdvertisesDirectStreamResume: Bool = false, - originStreamClock: PlaybackOriginStreamClock = SystemPlaybackOriginStreamClock() + originStreamClock: PlaybackOriginStreamClock = SystemPlaybackOriginStreamClock(), + startupPrefetchMaximumAheadBytes: Int64? = nil ) { self.resource = PlaybackSourceResource( originURL: originURL, @@ -2084,7 +2110,8 @@ final class PlaybackSourceProxy { outageRideThroughEnabled: outageRideThroughEnabled, resumeCapable: resumeCapable, serverAdvertisesDirectStreamResume: serverAdvertisesDirectStreamResume, - originStreamClock: originStreamClock + originStreamClock: originStreamClock, + startupPrefetchMaximumAheadBytes: startupPrefetchMaximumAheadBytes ) } @@ -2186,6 +2213,10 @@ final class PlaybackSourceProxy { resource.startPrefetch(at: offset) } + func releaseStartupPrefetchLimit() { + resource.releaseStartupPrefetchLimit() + } + /// Swap the origin endpoint in place after a silent session renewal. /// See `PlaybackSourceResource.retargetOrigin`. func retargetOrigin(url: URL, headers: [String: String]) { diff --git a/iosApp/iosApp/Screens/Player/PlayerViewModel.swift b/iosApp/iosApp/Screens/Player/PlayerViewModel.swift index 780e1c39..9d122564 100644 --- a/iosApp/iosApp/Screens/Player/PlayerViewModel.swift +++ b/iosApp/iosApp/Screens/Player/PlayerViewModel.swift @@ -1490,6 +1490,10 @@ class PlayerViewModel { callbackGeneration, currentGeneration: self.streamLoadGeneration ) else { return } + // SiloPlayer's MKV planner has finished its latency-sensitive + // startup probes and AVPlayer has advanced. Restore the full + // source-cache readahead window for steady-state resilience. + self.sourceProxy?.releaseStartupPrefetchLimit() Task { await self.sessionBridge.reportProtocolV3FirstFrame(milliseconds: milliseconds) } } cb.onError = { [weak self] message in @@ -2807,7 +2811,10 @@ class PlayerViewModel { } }, resumeCapable: resumeCapable, - serverAdvertisesDirectStreamResume: serverAdvertisesDirectStreamResume + serverAdvertisesDirectStreamResume: serverAdvertisesDirectStreamResume, + startupPrefetchMaximumAheadBytes: plan.engine == .siloPlayerLoopback + ? PlaybackSourcePrefetchPolicy.loopbackStartupMaximumAheadBytes + : nil ) do { try await proxy.start() From 4fad550c58ba2472ffca0258836d67d939237098 Mon Sep 17 00:00:00 2001 From: blurbery Date: Fri, 14 Aug 2026 11:12:50 +1000 Subject: [PATCH 2/2] perf(playback): start remote HLS immediately --- iosApp/Tests/LoopbackBufferPolicyTests.swift | 16 +++ .../AVPlayerRoute/AVPlayerBackend.swift | 114 +++++++++++++----- 2 files changed, 97 insertions(+), 33 deletions(-) diff --git a/iosApp/Tests/LoopbackBufferPolicyTests.swift b/iosApp/Tests/LoopbackBufferPolicyTests.swift index b2406010..931c3ed8 100644 --- a/iosApp/Tests/LoopbackBufferPolicyTests.swift +++ b/iosApp/Tests/LoopbackBufferPolicyTests.swift @@ -2,6 +2,22 @@ import XCTest @testable import Silo final class LoopbackBufferPolicyTests: XCTestCase { + func testRemoteHLSUsesOneSecondFastStartPolicy() { + let policy = AVPlayerBackend.startupBufferPolicy( + for: .remoteHLS(url: URL(string: "https://silo.invalid/master.m3u8")!, headers: [:]) + ) + + XCTAssertEqual(policy, .fastStart(forwardBufferDuration: 1)) + } + + func testRemoteDirectKeepsSystemStartupBuffering() { + let policy = AVPlayerBackend.startupBufferPolicy( + for: .remoteDirect(url: URL(string: "https://silo.invalid/video.mp4")!, headers: [:]) + ) + + XCTAssertEqual(policy, .systemDefault) + } + func testEventGeneratedMediaBitrateDrivesSteadyStateBufferTarget() { let target = AVPlayerBackend.loopbackSteadyStateForwardBufferTarget( forBitsPerSecond: 69_000_000, diff --git a/iosApp/iosApp/Screens/Player/AVPlayerRoute/AVPlayerBackend.swift b/iosApp/iosApp/Screens/Player/AVPlayerRoute/AVPlayerBackend.swift index 54d00f3b..29a2e73c 100644 --- a/iosApp/iosApp/Screens/Player/AVPlayerRoute/AVPlayerBackend.swift +++ b/iosApp/iosApp/Screens/Player/AVPlayerRoute/AVPlayerBackend.swift @@ -384,6 +384,11 @@ final class AVPlayerBackend { case siloLoopback(spec: LoopbackSessionSpec) } + enum StartupBufferPolicy: Equatable { + case systemDefault + case fastStart(forwardBufferDuration: Double) + } + private struct MediaSelectionState { let kind: PlayerTrack.Kind let group: AVMediaSelectionGroup @@ -399,6 +404,10 @@ final class AVPlayerBackend { /// creation. Sized for fast initial readyToPlay — AVPlayer otherwise /// waits for whole GOP-sized fragments before declaring ready. private static let loopbackStartupForwardBuffer: Double = 4.0 + /// Server HLS already arrives as short, independently playable media + /// fragments. Keep only a small explicit startup cushion so AVPlayer does + /// not add several seconds of conservative pre-roll after media is ready. + private static let remoteHLSStartupForwardBuffer: Double = 1.0 /// Local loopback startup watchdog (AetherEngine-style, replaces the old /// fixed 12 s readiness timeout that killed healthy-but-slow startups — /// living-room DV P7 + TrueHD→FLAC at a far resume needed >12 s while @@ -530,6 +539,17 @@ final class AVPlayerBackend { loopbackStartupForwardBuffer } + static func startupBufferPolicy(for strategy: SourceStrategy?) -> StartupBufferPolicy { + switch strategy { + case .some(.remoteHLS): + return .fastStart(forwardBufferDuration: remoteHLSStartupForwardBuffer) + case .some(.siloLoopback): + return .fastStart(forwardBufferDuration: loopbackStartupForwardBuffer) + case .some(.remoteDirect), .none: + return .systemDefault + } + } + private static func loopbackLiveEdgeForwardBufferFloor( targetDuration: Double?, longestSegmentDuration: Double? @@ -2185,7 +2205,7 @@ final class AVPlayerBackend { if previousBitrate == nil, bitsPerSecond != nil, let item = self.currentItem, self.canRampLoopbackBufferToSteadyState { - self.rampLoopbackBufferToSteadyStateIfNeeded(for: item) + self.transitionStartupBufferToSteadyStateIfNeeded(for: item) } } } @@ -2197,7 +2217,7 @@ final class AVPlayerBackend { self.emitPlaybackStats(referenceTime: self.currentTime(), force: true) if let item = self.currentItem, self.canRampLoopbackBufferToSteadyState { - self.rampLoopbackBufferToSteadyStateIfNeeded(for: item) + self.transitionStartupBufferToSteadyStateIfNeeded(for: item) self.sampleLocalLoopbackEdge(item: item, referenceTime: self.currentTime(), trigger: "generated_stats") } } @@ -2347,16 +2367,25 @@ final class AVPlayerBackend { // over the source's bitrate is small (e.g. 4K DV at 72 Mbps over // 80 Mbps). // - // Remote routes keep AVPlayer's defaults since automatic buffering - // is genuinely useful over the WAN. - if case .siloLoopback = currentSourceStrategy { + // Server HLS gets the same two-phase treatment with a smaller startup + // target: start from the first available fragment, then restore + // AVPlayer's automatic WAN buffering immediately after the first + // displayed frame. Direct-file playback keeps the system defaults. + switch Self.startupBufferPolicy(for: currentSourceStrategy) { + case .fastStart(let forwardBufferDuration): avPlayer.automaticallyWaitsToMinimizeStalling = false - item.preferredForwardBufferDuration = Self.loopbackStartupForwardBuffer - // Do not let AVPlayer poll the local EVENT playlist while paused. - // Under disk pressure the writer may pause appends until playback - // frees spill capacity; paused polling can therefore see an - // unchanged playlist long enough for CoreMedia to fail the item. - item.canUseNetworkResourcesForLiveStreamingWhilePaused = false + item.preferredForwardBufferDuration = forwardBufferDuration + if case .siloLoopback = currentSourceStrategy { + // Do not let AVPlayer poll the local EVENT playlist while + // paused. Under disk pressure the writer may pause appends + // until playback frees spill capacity; paused polling can + // therefore see an unchanged playlist long enough for + // CoreMedia to fail the item. + item.canUseNetworkResourcesForLiveStreamingWhilePaused = false + } + case .systemDefault: + avPlayer.automaticallyWaitsToMinimizeStalling = true + item.preferredForwardBufferDuration = 0 } currentItem = item beginInitialVideoDisplayGate() @@ -3722,7 +3751,13 @@ final class AVPlayerBackend { private func startPlaybackIfNeeded(for item: AVPlayerItem) { armInitialVideoDisplayGateIfNeeded() - avPlayer.play() + if isWaitingForInitialVideoDisplay, + case .fastStart = Self.startupBufferPolicy(for: currentSourceStrategy) { + avPlayer.playImmediately(atRate: 1.0) + Self.logger.info("[CMP-AVP] requested immediate startup playback") + } else { + avPlayer.play() + } if isWaitingForInitialVideoDisplay { scheduleInitialVideoDisplayFallback(for: item) } else { @@ -3934,7 +3969,7 @@ final class AVPlayerBackend { avPlayer.isMuted = false didTemporarilyMuteForInitialVideoDisplay = false } - rampLoopbackBufferToSteadyStateIfNeeded(for: item) + transitionStartupBufferToSteadyStateIfNeeded(for: item) Self.logger.info("[CMP-AVP] initial video display gate released reason=\(reason, privacy: .public)") } @@ -3942,27 +3977,40 @@ final class AVPlayerBackend { /// state target and re-enable automatic waiting. EVENT playlists expand /// their live-edge cushion; static VOD playlists stay near one segment so /// AVPlayer cannot outrun the bounded producer window. - private func rampLoopbackBufferToSteadyStateIfNeeded(for item: AVPlayerItem) { - guard case .siloLoopback(let spec) = currentSourceStrategy else { return } - guard canRampLoopbackBufferToSteadyState else { return } - let generatedStats = latestLoopbackGeneratedStats - let mediaBitrate = generatedStats?.rollingBitrateBps ?? spec.sourceBitrateBps - let target = Self.loopbackSteadyStateForwardBufferTarget( - forBitsPerSecond: mediaBitrate, - targetDuration: generatedStats.map { Double($0.targetDuration) }, - longestSegmentDuration: generatedStats?.longestSegmentDuration, - servingMode: spec.servingMode - ) - let shouldRaiseForwardBuffer = item.preferredForwardBufferDuration < target - let shouldEnableAutomaticWaiting = !avPlayer.automaticallyWaitsToMinimizeStalling - guard shouldRaiseForwardBuffer || shouldEnableAutomaticWaiting else { return } - if shouldRaiseForwardBuffer { - item.preferredForwardBufferDuration = target + private func transitionStartupBufferToSteadyStateIfNeeded(for item: AVPlayerItem) { + switch currentSourceStrategy { + case .some(.remoteHLS): + let shouldResetForwardBuffer = item.preferredForwardBufferDuration != 0 + let shouldEnableAutomaticWaiting = !avPlayer.automaticallyWaitsToMinimizeStalling + guard shouldResetForwardBuffer || shouldEnableAutomaticWaiting else { return } + item.preferredForwardBufferDuration = 0 + avPlayer.automaticallyWaitsToMinimizeStalling = true + Self.logger.info( + "[CMP-AVP] remote HLS startup buffer released forwardBuffer=system automaticallyWaits=1" + ) + case .some(.siloLoopback(let spec)): + guard canRampLoopbackBufferToSteadyState else { return } + let generatedStats = latestLoopbackGeneratedStats + let mediaBitrate = generatedStats?.rollingBitrateBps ?? spec.sourceBitrateBps + let target = Self.loopbackSteadyStateForwardBufferTarget( + forBitsPerSecond: mediaBitrate, + targetDuration: generatedStats.map { Double($0.targetDuration) }, + longestSegmentDuration: generatedStats?.longestSegmentDuration, + servingMode: spec.servingMode + ) + let shouldRaiseForwardBuffer = item.preferredForwardBufferDuration < target + let shouldEnableAutomaticWaiting = !avPlayer.automaticallyWaitsToMinimizeStalling + guard shouldRaiseForwardBuffer || shouldEnableAutomaticWaiting else { return } + if shouldRaiseForwardBuffer { + item.preferredForwardBufferDuration = target + } + avPlayer.automaticallyWaitsToMinimizeStalling = true + Self.logger.info( + "[CMP-AVP] loopback buffer ramp servingMode=\(String(describing: spec.servingMode), privacy: .public) forwardBuffer=\(target, privacy: .public)s automaticallyWaits=1 mediaBitrate=\(mediaBitrate ?? 0, privacy: .public)bps generatedBitrate=\(generatedStats?.rollingBitrateBps ?? 0, privacy: .public)bps declaredBitrate=\(spec.sourceBitrateBps ?? 0, privacy: .public)bps sourceReadBitrate=\(self.loopbackSourceDownloadBitrateBps ?? 0, privacy: .public)bps targetDuration=\(generatedStats?.targetDuration ?? 0, privacy: .public) longestSegment=\(generatedStats?.longestSegmentDuration ?? 0, privacy: .public)" + ) + case .some(.remoteDirect), .none: + return } - avPlayer.automaticallyWaitsToMinimizeStalling = true - Self.logger.info( - "[CMP-AVP] loopback buffer ramp servingMode=\(String(describing: spec.servingMode), privacy: .public) forwardBuffer=\(target, privacy: .public)s automaticallyWaits=1 mediaBitrate=\(mediaBitrate ?? 0, privacy: .public)bps generatedBitrate=\(generatedStats?.rollingBitrateBps ?? 0, privacy: .public)bps declaredBitrate=\(spec.sourceBitrateBps ?? 0, privacy: .public)bps sourceReadBitrate=\(self.loopbackSourceDownloadBitrateBps ?? 0, privacy: .public)bps targetDuration=\(generatedStats?.targetDuration ?? 0, privacy: .public) longestSegment=\(generatedStats?.longestSegmentDuration ?? 0, privacy: .public)" - ) } private var canRampLoopbackBufferToSteadyState: Bool {