From 43d1fc34ecade7a851e18ab4249a06719c380e2f Mon Sep 17 00:00:00 2001 From: Neureka Date: Mon, 28 Sep 2026 19:23:08 -0700 Subject: [PATCH 01/11] fix(native): keep a viewer's pause when reviving a dead player item An item that died with failedToPlayToEndTime while the viewer had it paused was reloaded through the stage-2 chain with the pause guard bypassed, and the reload then called play() on the fresh item. A paused session started playing again by itself, minutes after the pause. The bypass still admits the dead item, but the reload now restarts transport only when the host's durable transport intent is playing, so an item that died under an engine-routed pause comes back paused at its anchor and the next Play resumes there. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + Sources/AetherEngine/AetherEngine.swift | 8 +++++-- .../Native/Issue93ItemDeathRevive.swift | 13 +++++++++++ .../Issue93ItemDeathReviveTests.swift | 22 +++++++++++++++++++ docs/architecture.md | 2 +- 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be3e8769..708e7c542 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ the public-API contract. ### Fixed +- A paused video no longer starts playing by itself. When the player item died while paused (`failedToPlayToEndTime`), the recovery reload bypassed the pause guard and called `play()` on the fresh item. The reload now keeps the viewer's pause and mounts the item paused at the same position. - TrueHD Atmos rendered to APAC (`LoadOptions.objectAudioRendering`) no longer plays 42.7 ms ahead of the video. The bridge dropped the encoder's 2048 frames of priming and stamped the first content packet on the source position, but AVFoundation presents an APAC packet's audio 2048 frames before its timestamp, so every session ran early, at load and after every seek. The priming packets now stay in the stream and take the source position's timestamp. - Authorized native HLS uses the engine relay from the initial load, without forwarding origin credentials to the loopback asset. Optional subtitle playlist preparation shares the authorizer and has a bounded deadline across redirects and refreshes. - Static-header HLS redirects apply the shared credential policy, including Emby and MediaBrowser token headers, before contacting another origin. diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index b2adb8b62..f9345c8c2 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -2838,6 +2838,10 @@ public final class AetherEngine: ObservableObject { guard Self.stalledConsumerRecoveryAllowed( consumerIsPaused: player.timeControlStatus == .paused, allowPausedConsumer: allowPausedConsumer) else { return } + // Read before the swap: a dead item under a viewer's pause comes back paused. + let resumesPlaying = Self.stalledConsumerReloadResumesPlaying( + allowPausedConsumer: allowPausedConsumer, + transportIntentIsPlaying: host.transportIntentIsPlaying) // AE#422: mirror, not `currentTime()`. See `reengageStalledConsumer`; this path runs one // grace window deeper into the same stall. let anchor = Self.recoveryAnchorPosition( @@ -2873,7 +2877,7 @@ public final class AetherEngine: ObservableObject { + Self.recoveryAnchorLogSuffix( anchor: anchor, position: position, pendingSeekTarget: pendingRecoverySeekClockTarget) - + " (same URL, same host)", + + " (same URL, same host" + (resumesPlaying ? ")" : ", staying paused for the viewer)"), category: .engine ) // AE#454: the placement, expressed in the playlist the fresh item is about to load. A rejoin @@ -2913,7 +2917,7 @@ public final class AetherEngine: ObservableObject { // AE#454 round 2: the item that is about to load is the one the placement was armed for, and // the only one whose axis the playlist will state. if didArmPlacement { liveRejoinPlacementGeneration = host.itemGeneration } - host.play() + if resumesPlaying { host.play() } if let rejoinPosition { // Stashed rather than seeked: the pre-readiness seek IS the wedge LiveReloadPolicy exists // to avoid, and a live seek does not defer itself (`shouldDeferHostSeek` excludes live), so diff --git a/Sources/AetherEngine/Native/Issue93ItemDeathRevive.swift b/Sources/AetherEngine/Native/Issue93ItemDeathRevive.swift index 01885673b..4f7a640f7 100644 --- a/Sources/AetherEngine/Native/Issue93ItemDeathRevive.swift +++ b/Sources/AetherEngine/Native/Issue93ItemDeathRevive.swift @@ -45,4 +45,17 @@ extension AetherEngine { ) -> Bool { !consumerIsPaused || allowPausedConsumer } + + /// Pure decision: does a stage-2 item reload restart transport on the fresh item? The bypass + /// above admits a dead item past the pause guard; it must not also overrule the viewer. Item + /// death parks `timeControlStatus` at `.paused` but leaves the host's durable #122 intent + /// alone, and only an engine-routed pause clears it, so an item that died under a viewer's + /// pause is reloaded paused at its anchor and the next Play resumes there. Playing it instead + /// restarted a paused session on its own, minutes after the viewer paused it. Every other + /// stage-2 trigger refused a paused consumer on the way in, so it resumes as before. + nonisolated static func stalledConsumerReloadResumesPlaying( + allowPausedConsumer: Bool, transportIntentIsPlaying: Bool + ) -> Bool { + !allowPausedConsumer || transportIntentIsPlaying + } } diff --git a/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift b/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift index aaa1d744c..f0dd1c6f6 100644 --- a/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift +++ b/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift @@ -78,6 +78,28 @@ struct Issue93ItemDeathReviveTests { consumerIsPaused: false, allowPausedConsumer: false)) } + // MARK: - Transport after the reload + + @Test("an item that died while playing is reloaded playing") + func itemDeathWhilePlayingResumes() { + #expect(AetherEngine.stalledConsumerReloadResumesPlaying( + allowPausedConsumer: true, transportIntentIsPlaying: true)) + } + + @Test("an item that died under a viewer's pause is reloaded paused") + func itemDeathUnderViewerPauseStaysPaused() { + // The field report: paused on an Apple TV, the item died minutes later and the + // reload started playback with nobody touching the remote. + #expect(!AetherEngine.stalledConsumerReloadResumesPlaying( + allowPausedConsumer: true, transportIntentIsPlaying: false)) + } + + @Test("stall-driven reloads of a rolling consumer keep resuming") + func stallReloadResumes() { + #expect(AetherEngine.stalledConsumerReloadResumesPlaying( + allowPausedConsumer: false, transportIntentIsPlaying: true)) + } + // MARK: - Host-side counting decision @Test("loopback path counts an end failure after playback was established") diff --git a/docs/architecture.md b/docs/architecture.md index 6c6ee1d7c..9d49e2edd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,7 +64,7 @@ What a host observes about a seek comes in two shapes. `isSeeking` / `seekTarget Restart latency is self-localizing (#93 follow-up): the "producer restarted" line carries a phase split (`stopWait/reopen/seek/build`), a producer's FIRST source read is timed, and any single `AVIOReader` read exceeding 2 s emits one `slow read` summary naming where the time went (detour fetches with network time, `connStallTimeout` waits, reconnects, backoff sleeps, bytes dropped by the stale-generation guard, generation span). A slow read with all-zero counters means the wait was upstream of the read loop. -A restart-window request must also never leave AVPlayer waiting in silence (#93 round 3): AVPlayer's media watchdog logs `-12889 "No response for media file"` after ~3.5 s without response HEADERS (holding the connection open does not help), and three strikes fail the item. A VOD serve still running at 2 s (`SlowServeSignal` armed by `VideoSegmentProvider.mediaSegment(at:onSlow:)`) therefore emits an early `200` with `Transfer-Encoding: chunked`; the segment follows as a single chunk when it lands, and a serve that ultimately misses aborts the connection (truncated transfer, AVPlayer retries) instead of framing a cacheable empty 200. Fast serves keep the byte-identical `Content-Length` response. If the item dies anyway, `failedToPlayToEndTime` parks it at rate 0 / `timeControlStatus == .paused` (with `item.status` often still `readyToPlay`), which every pause-guarded recovery layer used to misread as user intent, making the session terminal. The host now counts loopback-path end failures (`endFailureCount`), and the engine confirms the death through the same deferred window as the `.failed` KVO, then reloads the item through the stage-2 chain with the pause guard bypassed, bounded by `ItemDeathReviveGate` (3 attempts per dead spot; playback progress or a user seek away restores the budget). +A restart-window request must also never leave AVPlayer waiting in silence (#93 round 3): AVPlayer's media watchdog logs `-12889 "No response for media file"` after ~3.5 s without response HEADERS (holding the connection open does not help), and three strikes fail the item. A VOD serve still running at 2 s (`SlowServeSignal` armed by `VideoSegmentProvider.mediaSegment(at:onSlow:)`) therefore emits an early `200` with `Transfer-Encoding: chunked`; the segment follows as a single chunk when it lands, and a serve that ultimately misses aborts the connection (truncated transfer, AVPlayer retries) instead of framing a cacheable empty 200. Fast serves keep the byte-identical `Content-Length` response. If the item dies anyway, `failedToPlayToEndTime` parks it at rate 0 / `timeControlStatus == .paused` (with `item.status` often still `readyToPlay`), which every pause-guarded recovery layer used to misread as user intent, making the session terminal. The host now counts loopback-path end failures (`endFailureCount`), and the engine confirms the death through the same deferred window as the `.failed` KVO, then reloads the item through the stage-2 chain with the pause guard bypassed, bounded by `ItemDeathReviveGate` (3 attempts per dead spot; playback progress or a user seek away restores the budget). The bypass only admits the dead item; it does not overrule the viewer. An item that dies under an engine-routed pause (the host's durable transport intent is paused) is reloaded paused at its anchor, and the next Play resumes there. When a restart does run, it must reproduce segments on the SAME media timeline the continuous run gave them: the loopback's contract with AVPlayer is "static VOD server", and AVPlayer anchors fMP4 segments by their `tfdt`. Each restart allocates a fresh mp4 muxer, and movenc zero-bases a new instance's timeline by default, so a restart-produced segment used to carry `tfdt=0` while the playlist placed it at its plan offset: an implicit timeline discontinuity on every restart, papered over for plain playback but fatal to ancillary consumers (AVKit's legible renderer detaches mid-PiP, Sodalite#32; playhead/loaded-range decoupling, #93). The muxer therefore sets `movflags +frag_discont` with `avoid_negative_ts=disabled` so `tfdt` carries the producer's absolute output timestamps, the restart audio gate inherits the session shift (video shift rescaled) instead of snapping audio onto the video seam, and leading head-of-stream audio that would map below 0 is dropped (the muxer no longer absorbs negative timestamps). A restarted segment is byte-identical to its continuous twin modulo the per-muxer `mfhd` sequence number (pinned by `RestartTimelineContinuityTests` on a committed A/V fixture); on matroska sources, per-sample DTS synthesis after a demuxer seek scatters the DTS decomposition and boundary-frame membership by a frame or two, but presentation timestamps and `tfdt` anchoring stay epoch-invariant. Because `tfdt` carries `unsigned int(64)`, that same setting makes a negative output axis unrepresentable rather than merely unusual, so the published first timestamp is clamped at zero (AE#509): libavformat serves an MPEG-TS whose first DTS sits within 60 s of the 33-bit PTS wrap with every timestamp `2^33` ticks low (`AV_PTS_WRAP_SUB_OFFSET`), and a live join there published `baseMediaDecodeTime = 2^64 - |dts|` against a playlist starting at 0, which AVPlayer answers by fetching the whole window and placing none of it, with no error and no stall of its own. From 3bf3aa25e4b75ba4899c1bc212abf925dfcc3159 Mon Sep 17 00:00:00 2001 From: Neureka Date: Mon, 28 Sep 2026 19:38:17 -0700 Subject: [PATCH 02/11] fix(native): judge the viewer's pause from AVPlayer's own transport The intent latch only moves on engine-routed play and pause. A pause from AVKit's transport bar, Control Center or PiP left it set, so an item that died under that pause still resumed; a Play from those surfaces after an engine pause left it clear, so an item that died while playing came back paused. The host now records when AVPlayer's timeControlStatus went paused and, when it counts an item death, whether the transport had already stopped before it. The dead item's own pause lands with the failure, so only a pause at least a second older keeps the reload paused. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- .../AetherEngine/AetherEngine+Loading.swift | 7 +++- Sources/AetherEngine/AetherEngine.swift | 8 ++-- .../Native/Issue93ItemDeathRevive.swift | 13 ------- .../Native/NativeAVPlayerHost.swift | 30 ++++++++++++++ .../Issue93ItemDeathReviveTests.swift | 39 ++++++++++++------- docs/architecture.md | 2 +- 7 files changed, 65 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 708e7c542..ee23e99f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ the public-API contract. ### Fixed -- A paused video no longer starts playing by itself. When the player item died while paused (`failedToPlayToEndTime`), the recovery reload bypassed the pause guard and called `play()` on the fresh item. The reload now keeps the viewer's pause and mounts the item paused at the same position. +- A paused video no longer starts playing by itself. When the player item died while paused (`failedToPlayToEndTime`), the recovery reload bypassed the pause guard and called `play()` on the fresh item. The reload now keeps a pause made before the item died, whether it came through the engine, AVKit, Control Center or PiP, and mounts the item paused at the same position. - TrueHD Atmos rendered to APAC (`LoadOptions.objectAudioRendering`) no longer plays 42.7 ms ahead of the video. The bridge dropped the encoder's 2048 frames of priming and stamped the first content packet on the source position, but AVFoundation presents an APAC packet's audio 2048 frames before its timestamp, so every session ran early, at load and after every seek. The priming packets now stay in the stream and take the source position's timestamp. - Authorized native HLS uses the engine relay from the initial load, without forwarding origin credentials to the loopback asset. Optional subtitle playlist preparation shares the authorizer and has a bounded deadline across redirects and refreshes. - Static-header HLS redirects apply the shared credential policy, including Emby and MediaBrowser token headers, before contacting another origin. diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index 6a1671974..b29b556ae 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -1556,6 +1556,7 @@ extension AetherEngine { .sink { [weak self, weak host] count in guard let self, let host else { return } let clockAtFailure = host.renderedTime + let diedUnderPause = host.endFailureFollowedPause self.itemDeathConfirmTask?.cancel() self.itemDeathConfirmTask = Task { @MainActor [weak self, weak host] in try? await Task.sleep( @@ -1588,9 +1589,11 @@ extension AetherEngine { EngineLog.emit( "[AetherEngine] #93 item death (failedToPlayToEndTime) at " + "\(String(format: "%.2f", position))s; reloading item through stage-2 " - + "recovery (attempt \(self.itemDeathReviveGate.attempts), pause guard bypassed)", + + "recovery (attempt \(self.itemDeathReviveGate.attempts), pause guard bypassed" + + (diedUnderPause ? ", died under the viewer's pause)" : ")"), category: .engine) - self.reloadStalledConsumerItem(position: position, allowPausedConsumer: true) + self.reloadStalledConsumerItem( + position: position, allowPausedConsumer: true, resumesPlaying: !diedUnderPause) } } .store(in: &nativeCancellables) diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index f9345c8c2..2591b573f 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -2830,7 +2830,11 @@ public final class AetherEngine: ObservableObject { /// `LiveReloadPolicy.recoveryRejoinPosition` cannot. A window closed with ENDLIST is a finite asset /// whose seekable end IS the playhead, so the distance-behind-live that policy reads is zero and it /// would aim at the edge, discarding the rewind the viewer kept through the whole outage. + /// - Parameter resumesPlaying: false for an item that died under a viewer's pause. The pause + /// guard bypass admits the dead item; it must not also overrule the viewer, so the fresh item + /// mounts paused at the anchor and the next Play resumes there. func reloadStalledConsumerItem(position: Double, allowPausedConsumer: Bool = false, + resumesPlaying: Bool = true, liveRejoinOverride: Double? = nil) { guard let host = nativeHost, let player = currentAVPlayer, let url = (player.currentItem?.asset as? AVURLAsset)?.url else { return } @@ -2838,10 +2842,6 @@ public final class AetherEngine: ObservableObject { guard Self.stalledConsumerRecoveryAllowed( consumerIsPaused: player.timeControlStatus == .paused, allowPausedConsumer: allowPausedConsumer) else { return } - // Read before the swap: a dead item under a viewer's pause comes back paused. - let resumesPlaying = Self.stalledConsumerReloadResumesPlaying( - allowPausedConsumer: allowPausedConsumer, - transportIntentIsPlaying: host.transportIntentIsPlaying) // AE#422: mirror, not `currentTime()`. See `reengageStalledConsumer`; this path runs one // grace window deeper into the same stall. let anchor = Self.recoveryAnchorPosition( diff --git a/Sources/AetherEngine/Native/Issue93ItemDeathRevive.swift b/Sources/AetherEngine/Native/Issue93ItemDeathRevive.swift index 4f7a640f7..01885673b 100644 --- a/Sources/AetherEngine/Native/Issue93ItemDeathRevive.swift +++ b/Sources/AetherEngine/Native/Issue93ItemDeathRevive.swift @@ -45,17 +45,4 @@ extension AetherEngine { ) -> Bool { !consumerIsPaused || allowPausedConsumer } - - /// Pure decision: does a stage-2 item reload restart transport on the fresh item? The bypass - /// above admits a dead item past the pause guard; it must not also overrule the viewer. Item - /// death parks `timeControlStatus` at `.paused` but leaves the host's durable #122 intent - /// alone, and only an engine-routed pause clears it, so an item that died under a viewer's - /// pause is reloaded paused at its anchor and the next Play resumes there. Playing it instead - /// restarted a paused session on its own, minutes after the viewer paused it. Every other - /// stage-2 trigger refused a paused consumer on the way in, so it resumes as before. - nonisolated static func stalledConsumerReloadResumesPlaying( - allowPausedConsumer: Bool, transportIntentIsPlaying: Bool - ) -> Bool { - !allowPausedConsumer || transportIntentIsPlaying - } } diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index cb4bb23b8..747addc12 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -124,6 +124,12 @@ final class NativeAVPlayerHost { /// at .paused, which every pause-guarded recovery layer misreads as user intent; the engine /// subscribes and escalates into the stage-2 item reload with the pause guard bypassed. @Published private(set) var endFailureCount: Int = 0 + /// Whether the transport had already stopped before the latest counted end failure: the viewer + /// paused (through the engine, AVKit, Control Center or PiP) and the item died under that pause. + /// Set before `endFailureCount` publishes, so its subscribers read the value for their failure. + private(set) var endFailureFollowedPause = false + /// Uptime at which the mirrored `timeControlStatus` became `.paused`; nil while the transport rolls. + private var pausedSinceUptime: UInt64? /// End of the last seekable time range (seconds); tracks the live edge for EVENT playlists. /// KVO mirror of `seekableTimeRanges`, NOT a live read: the getter is a sync XPC round-trip /// to mediaserverd, and clock-tick sinks plus the 1 Hz paused-live timer read this at a @@ -669,6 +675,11 @@ final class NativeAVPlayerHost { guard let self, self.sessionID == sid else { return } // AE#287: swallow the pause AVPlayer takes while a premature-end recovery re-seeks. if status == .paused, self.prematureEndRecoveryInFlight { return } + if status != .paused { + self.pausedSinceUptime = nil + } else if self.pausedSinceUptime == nil { + self.pausedSinceUptime = DispatchTime.now().uptimeNanoseconds + } self.timeControlStatus = status self.startLiveJoinImmediatelyIfHolding(waitingReason: reason) // First .playing: re-sample route after 2.5s settle -- AVKit only negotiates HDMI format on playback start (issue #24). @@ -745,6 +756,9 @@ final class NativeAVPlayerHost { surfaceEndFailures: false, hasEverPlayed: self.hasEverPlayed) { // #93 round 3: loopback path. Count the death for the engine's revive // escalation; a startup death (never played) stays with the startup watchdogs. + self.endFailureFollowedPause = Self.transportPausedBeforeFailure( + pausedSinceUptime: self.pausedSinceUptime, + failureUptime: DispatchTime.now().uptimeNanoseconds) self.endFailureCount += 1 } } @@ -896,6 +910,22 @@ final class NativeAVPlayerHost { !surfaceEndFailures && hasEverPlayed } + /// The dead item's own `.paused` and its `failedToPlayToEndTime` land within a runloop turn of + /// each other, in either order (the two are unsynchronized, see #50). A pause older than this + /// was the viewer's. + nonisolated static let pausedBeforeFailureMarginSeconds: Double = 1.0 + + /// Pure decision: had the transport already stopped when the item died? Read from AVPlayer's + /// own `timeControlStatus` rather than the #122 intent latch, because AVKit's transport bar, + /// Control Center and PiP pause and resume the player without passing through the engine. + nonisolated static func transportPausedBeforeFailure( + pausedSinceUptime: UInt64?, failureUptime: UInt64 + ) -> Bool { + guard let pausedSinceUptime, failureUptime > pausedSinceUptime else { return false } + let pausedSeconds = Double(failureUptime - pausedSinceUptime) / 1_000_000_000 + return pausedSeconds >= pausedBeforeFailureMarginSeconds + } + /// #50: AVPlayer fires .failed for self-healing transients (loopback 404, AVIOReader reconnect) while playback advances uninterrupted (rrgomes: tcs=playing at .failed). /// Discriminates on hasEverPlayed, not instantaneous timeControlStatus: .failed and timeControlStatus KVOs are unsynchronized (426b45c: still published terminal failure at 27.3s while AVPlayer played smoothly). /// Before first .playing: surface promptly (genuine startup failure). After: defer 5s and confirm -- clear if .playing or clock advanced, surface if both stopped. diff --git a/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift b/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift index f0dd1c6f6..395c72683 100644 --- a/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift +++ b/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift @@ -78,26 +78,35 @@ struct Issue93ItemDeathReviveTests { consumerIsPaused: false, allowPausedConsumer: false)) } - // MARK: - Transport after the reload + // MARK: - Viewer pause before the death - @Test("an item that died while playing is reloaded playing") - func itemDeathWhilePlayingResumes() { - #expect(AetherEngine.stalledConsumerReloadResumesPlaying( - allowPausedConsumer: true, transportIntentIsPlaying: true)) - } + private static let second: UInt64 = 1_000_000_000 @Test("an item that died under a viewer's pause is reloaded paused") - func itemDeathUnderViewerPauseStaysPaused() { - // The field report: paused on an Apple TV, the item died minutes later and the - // reload started playback with nobody touching the remote. - #expect(!AetherEngine.stalledConsumerReloadResumesPlaying( - allowPausedConsumer: true, transportIntentIsPlaying: false)) + func deathUnderViewerPause() { + // The field report: paused on an Apple TV, the item died minutes later and the reload + // started playback with nobody touching the remote. + #expect(NativeAVPlayerHost.transportPausedBeforeFailure( + pausedSinceUptime: 10 * Self.second, failureUptime: 460 * Self.second)) + } + + @Test("an item that died while rolling is reloaded playing") + func deathWhileRolling() { + #expect(!NativeAVPlayerHost.transportPausedBeforeFailure( + pausedSinceUptime: nil, failureUptime: 460 * Self.second)) + } + + @Test("the dead item's own pause, landing just before the notification, is not the viewer's") + func deathParksItsOwnPause() { + #expect(!NativeAVPlayerHost.transportPausedBeforeFailure( + pausedSinceUptime: 460 * Self.second - Self.second / 20, + failureUptime: 460 * Self.second)) } - @Test("stall-driven reloads of a rolling consumer keep resuming") - func stallReloadResumes() { - #expect(AetherEngine.stalledConsumerReloadResumesPlaying( - allowPausedConsumer: false, transportIntentIsPlaying: true)) + @Test("a pause stamped after the notification is not the viewer's") + func pauseAfterNotification() { + #expect(!NativeAVPlayerHost.transportPausedBeforeFailure( + pausedSinceUptime: 461 * Self.second, failureUptime: 460 * Self.second)) } // MARK: - Host-side counting decision diff --git a/docs/architecture.md b/docs/architecture.md index 9d49e2edd..6a0fdae68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,7 +64,7 @@ What a host observes about a seek comes in two shapes. `isSeeking` / `seekTarget Restart latency is self-localizing (#93 follow-up): the "producer restarted" line carries a phase split (`stopWait/reopen/seek/build`), a producer's FIRST source read is timed, and any single `AVIOReader` read exceeding 2 s emits one `slow read` summary naming where the time went (detour fetches with network time, `connStallTimeout` waits, reconnects, backoff sleeps, bytes dropped by the stale-generation guard, generation span). A slow read with all-zero counters means the wait was upstream of the read loop. -A restart-window request must also never leave AVPlayer waiting in silence (#93 round 3): AVPlayer's media watchdog logs `-12889 "No response for media file"` after ~3.5 s without response HEADERS (holding the connection open does not help), and three strikes fail the item. A VOD serve still running at 2 s (`SlowServeSignal` armed by `VideoSegmentProvider.mediaSegment(at:onSlow:)`) therefore emits an early `200` with `Transfer-Encoding: chunked`; the segment follows as a single chunk when it lands, and a serve that ultimately misses aborts the connection (truncated transfer, AVPlayer retries) instead of framing a cacheable empty 200. Fast serves keep the byte-identical `Content-Length` response. If the item dies anyway, `failedToPlayToEndTime` parks it at rate 0 / `timeControlStatus == .paused` (with `item.status` often still `readyToPlay`), which every pause-guarded recovery layer used to misread as user intent, making the session terminal. The host now counts loopback-path end failures (`endFailureCount`), and the engine confirms the death through the same deferred window as the `.failed` KVO, then reloads the item through the stage-2 chain with the pause guard bypassed, bounded by `ItemDeathReviveGate` (3 attempts per dead spot; playback progress or a user seek away restores the budget). The bypass only admits the dead item; it does not overrule the viewer. An item that dies under an engine-routed pause (the host's durable transport intent is paused) is reloaded paused at its anchor, and the next Play resumes there. +A restart-window request must also never leave AVPlayer waiting in silence (#93 round 3): AVPlayer's media watchdog logs `-12889 "No response for media file"` after ~3.5 s without response HEADERS (holding the connection open does not help), and three strikes fail the item. A VOD serve still running at 2 s (`SlowServeSignal` armed by `VideoSegmentProvider.mediaSegment(at:onSlow:)`) therefore emits an early `200` with `Transfer-Encoding: chunked`; the segment follows as a single chunk when it lands, and a serve that ultimately misses aborts the connection (truncated transfer, AVPlayer retries) instead of framing a cacheable empty 200. Fast serves keep the byte-identical `Content-Length` response. If the item dies anyway, `failedToPlayToEndTime` parks it at rate 0 / `timeControlStatus == .paused` (with `item.status` often still `readyToPlay`), which every pause-guarded recovery layer used to misread as user intent, making the session terminal. The host now counts loopback-path end failures (`endFailureCount`), and the engine confirms the death through the same deferred window as the `.failed` KVO, then reloads the item through the stage-2 chain with the pause guard bypassed, bounded by `ItemDeathReviveGate` (3 attempts per dead spot; playback progress or a user seek away restores the budget). The bypass only admits the dead item; it does not overrule the viewer. The host records when AVPlayer's own `timeControlStatus` went `.paused`, so a pause from the engine, AVKit, Control Center or PiP counts alike, and an item whose transport had already stopped before it died (`endFailureFollowedPause`) is reloaded paused at its anchor; the next Play resumes there. When a restart does run, it must reproduce segments on the SAME media timeline the continuous run gave them: the loopback's contract with AVPlayer is "static VOD server", and AVPlayer anchors fMP4 segments by their `tfdt`. Each restart allocates a fresh mp4 muxer, and movenc zero-bases a new instance's timeline by default, so a restart-produced segment used to carry `tfdt=0` while the playlist placed it at its plan offset: an implicit timeline discontinuity on every restart, papered over for plain playback but fatal to ancillary consumers (AVKit's legible renderer detaches mid-PiP, Sodalite#32; playhead/loaded-range decoupling, #93). The muxer therefore sets `movflags +frag_discont` with `avoid_negative_ts=disabled` so `tfdt` carries the producer's absolute output timestamps, the restart audio gate inherits the session shift (video shift rescaled) instead of snapping audio onto the video seam, and leading head-of-stream audio that would map below 0 is dropped (the muxer no longer absorbs negative timestamps). A restarted segment is byte-identical to its continuous twin modulo the per-muxer `mfhd` sequence number (pinned by `RestartTimelineContinuityTests` on a committed A/V fixture); on matroska sources, per-sample DTS synthesis after a demuxer seek scatters the DTS decomposition and boundary-frame membership by a frame or two, but presentation timestamps and `tfdt` anchoring stay epoch-invariant. Because `tfdt` carries `unsigned int(64)`, that same setting makes a negative output axis unrepresentable rather than merely unusual, so the published first timestamp is clamped at zero (AE#509): libavformat serves an MPEG-TS whose first DTS sits within 60 s of the 33-bit PTS wrap with every timestamp `2^33` ticks low (`AV_PTS_WRAP_SUB_OFFSET`), and a live join there published `baseMediaDecodeTime = 2^64 - |dts|` against a playlist starting at 0, which AVPlayer answers by fetching the whole window and placing none of it, with no error and no stall of its own. From 98fcdd7c5f7e82b3ac09df090cd2ed2931f8dc76 Mon Sep 17 00:00:00 2001 From: Neureka Date: Mon, 28 Sep 2026 19:59:02 -0700 Subject: [PATCH 03/11] fix(native): decide a dead item's transport at reload and clear the latch The reload took its play-or-pause decision when the failure was counted, three seconds before it ran, so a Play or Pause pressed while the death was being confirmed was ignored. It now decides at reload time: an engine-routed press since the failure wins, a transport rolling again resumes, and one that had stopped before the failure stays paused. When the reload stays paused it now calls pause() on the host. A pause from AVKit, Control Center or PiP left the intent latch set, and the fresh item's readyToPlay would have re-asserted play(). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../AetherEngine/AetherEngine+Loading.swift | 10 +++++++-- Sources/AetherEngine/AetherEngine.swift | 15 +++++++++---- .../Native/NativeAVPlayerHost.swift | 18 +++++++++++++++ .../Issue93ItemDeathReviveTests.swift | 22 +++++++++++++++++++ 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index b29b556ae..f17e90614 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -1586,14 +1586,20 @@ extension AetherEngine { ) return } + // Decided now rather than when the failure was counted: the viewer may have + // pressed Play or Pause while the death was being confirmed. + let resumesPlaying = NativeAVPlayerHost.itemDeathReloadResumesPlaying( + diedUnderPause: diedUnderPause, + commandSinceFailure: host.transportCommandSinceEndFailure, + transportRolling: host.timeControlStatus != .paused) EngineLog.emit( "[AetherEngine] #93 item death (failedToPlayToEndTime) at " + "\(String(format: "%.2f", position))s; reloading item through stage-2 " + "recovery (attempt \(self.itemDeathReviveGate.attempts), pause guard bypassed" - + (diedUnderPause ? ", died under the viewer's pause)" : ")"), + + (resumesPlaying ? ")" : ", keeping the viewer's pause)"), category: .engine) self.reloadStalledConsumerItem( - position: position, allowPausedConsumer: true, resumesPlaying: !diedUnderPause) + position: position, allowPausedConsumer: true, resumesPlaying: resumesPlaying) } } .store(in: &nativeCancellables) diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 2591b573f..811317357 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -2830,9 +2830,10 @@ public final class AetherEngine: ObservableObject { /// `LiveReloadPolicy.recoveryRejoinPosition` cannot. A window closed with ENDLIST is a finite asset /// whose seekable end IS the playhead, so the distance-behind-live that policy reads is zero and it /// would aim at the edge, discarding the rewind the viewer kept through the whole outage. - /// - Parameter resumesPlaying: false for an item that died under a viewer's pause. The pause - /// guard bypass admits the dead item; it must not also overrule the viewer, so the fresh item - /// mounts paused at the anchor and the next Play resumes there. + /// - Parameter resumesPlaying: false when the viewer wants the item paused (see + /// `NativeAVPlayerHost.itemDeathReloadResumesPlaying`). The pause guard bypass admits the dead + /// item; it must not also overrule the viewer, so the fresh item mounts paused at the anchor + /// and the next Play resumes there. func reloadStalledConsumerItem(position: Double, allowPausedConsumer: Bool = false, resumesPlaying: Bool = true, liveRejoinOverride: Double? = nil) { @@ -2917,7 +2918,13 @@ public final class AetherEngine: ObservableObject { // AE#454 round 2: the item that is about to load is the one the placement was armed for, and // the only one whose axis the playlist will state. if didArmPlacement { liveRejoinPlacementGeneration = host.itemGeneration } - if resumesPlaying { host.play() } + if resumesPlaying { + host.play() + } else { + // Clears the intent latch a pause from AVKit, Control Center or PiP left set, so the fresh + // item's readyToPlay does not re-assert play() behind the viewer. + host.pause() + } if let rejoinPosition { // Stashed rather than seeked: the pre-readiness seek IS the wedge LiveReloadPolicy exists // to avoid, and a live seek does not defer itself (`shouldDeferHostSeek` excludes live), so diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index 747addc12..b0f26196a 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -128,6 +128,10 @@ final class NativeAVPlayerHost { /// paused (through the engine, AVKit, Control Center or PiP) and the item died under that pause. /// Set before `endFailureCount` publishes, so its subscribers read the value for their failure. private(set) var endFailureFollowedPause = false + /// The latest engine-routed transport command since the latest counted end failure: true for + /// play, false for pause, nil for none. A viewer can press either while the engine confirms the + /// death, and that press outranks the transport state the item died in. + private(set) var transportCommandSinceEndFailure: Bool? /// Uptime at which the mirrored `timeControlStatus` became `.paused`; nil while the transport rolls. private var pausedSinceUptime: UInt64? /// End of the last seekable time range (seconds); tracks the live edge for EVENT playlists. @@ -759,6 +763,7 @@ final class NativeAVPlayerHost { self.endFailureFollowedPause = Self.transportPausedBeforeFailure( pausedSinceUptime: self.pausedSinceUptime, failureUptime: DispatchTime.now().uptimeNanoseconds) + self.transportCommandSinceEndFailure = nil self.endFailureCount += 1 } } @@ -926,6 +931,16 @@ final class NativeAVPlayerHost { return pausedSeconds >= pausedBeforeFailureMarginSeconds } + /// Pure decision: does the reload of a dead item restart transport? A Play or Pause pressed + /// through the engine after the failure decides. Otherwise a transport rolling again (a Play from + /// AVKit, Control Center or PiP) resumes, and one that had stopped before the failure stays paused. + nonisolated static func itemDeathReloadResumesPlaying( + diedUnderPause: Bool, commandSinceFailure: Bool?, transportRolling: Bool + ) -> Bool { + if let commandSinceFailure { return commandSinceFailure } + return transportRolling || !diedUnderPause + } + /// #50: AVPlayer fires .failed for self-healing transients (loopback 404, AVIOReader reconnect) while playback advances uninterrupted (rrgomes: tcs=playing at .failed). /// Discriminates on hasEverPlayed, not instantaneous timeControlStatus: .failed and timeControlStatus KVOs are unsynchronized (426b45c: still published terminal failure at 27.3s while AVPlayer played smoothly). /// Before first .playing: surface promptly (genuine startup failure). After: defer 5s and confirm -- clear if .playing or clock advanced, surface if both stopped. @@ -1667,12 +1682,14 @@ final class NativeAVPlayerHost { func play() { // Set intent before play() so readyToPlay observer can re-assert if the replaceCurrentItem swap swallowed it. playIntent = true + transportCommandSinceEndFailure = true // Call play() immediately (no defer-until-ready): item.status never advances past .unknown until AVPlayer is told to play. avPlayer.play() } func pause() { playIntent = false + transportCommandSinceEndFailure = false avPlayer.pause() } @@ -1918,6 +1935,7 @@ final class NativeAVPlayerHost { func setRate(_ value: Float) { // Non-zero rate counts as play intent (must survive replaceCurrentItem swap like play() does). playIntent = (value != 0) + transportCommandSinceEndFailure = (value != 0) // #436: `play()` is rate 1.0 by definition, and it is re-issued from paths no client can see: // the readyToPlay re-assert after an item swap, interruption and background resume, the #287 // premature-end recovery, plus AVKit's own transport and the remote command centre calling diff --git a/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift b/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift index 395c72683..91b13029c 100644 --- a/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift +++ b/Tests/AetherEngineTests/Issue93ItemDeathReviveTests.swift @@ -109,6 +109,28 @@ struct Issue93ItemDeathReviveTests { pausedSinceUptime: 461 * Self.second, failureUptime: 460 * Self.second)) } + @Test("with no press since the death, the transport it died in decides") + func reloadFollowsTransportAtDeath() { + #expect(!NativeAVPlayerHost.itemDeathReloadResumesPlaying( + diedUnderPause: true, commandSinceFailure: nil, transportRolling: false)) + #expect(NativeAVPlayerHost.itemDeathReloadResumesPlaying( + diedUnderPause: false, commandSinceFailure: nil, transportRolling: false)) + } + + @Test("a Play or Pause pressed while the death is confirmed outranks the transport it died in") + func pressDuringConfirmationDecides() { + #expect(NativeAVPlayerHost.itemDeathReloadResumesPlaying( + diedUnderPause: true, commandSinceFailure: true, transportRolling: false)) + #expect(!NativeAVPlayerHost.itemDeathReloadResumesPlaying( + diedUnderPause: false, commandSinceFailure: false, transportRolling: true)) + } + + @Test("a Play from outside the engine after a paused death resumes the reload") + func externalPlayAfterPausedDeath() { + #expect(NativeAVPlayerHost.itemDeathReloadResumesPlaying( + diedUnderPause: true, commandSinceFailure: nil, transportRolling: true)) + } + // MARK: - Host-side counting decision @Test("loopback path counts an end failure after playback was established") From 320ab2cbddea8175aea2f84465d758722de4dcb4 Mon Sep 17 00:00:00 2001 From: Neureka Date: Mon, 28 Sep 2026 20:30:23 -0700 Subject: [PATCH 04/11] fix(native): keep the pause timestamp in step after premature-end recovery The premature-end recovery republishes timeControlStatus directly after swallowing the pause it causes, which bypassed the pause timestamp the item-death reload reads. Both paths now go through one helper. The architecture note also states the one pause the reload cannot see: a pause from AVKit or Control Center during the confirmation window. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Native/NativeAVPlayerHost.swift | 19 ++++++++++++------- docs/architecture.md | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index b0f26196a..4080fb903 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -679,12 +679,7 @@ final class NativeAVPlayerHost { guard let self, self.sessionID == sid else { return } // AE#287: swallow the pause AVPlayer takes while a premature-end recovery re-seeks. if status == .paused, self.prematureEndRecoveryInFlight { return } - if status != .paused { - self.pausedSinceUptime = nil - } else if self.pausedSinceUptime == nil { - self.pausedSinceUptime = DispatchTime.now().uptimeNanoseconds - } - self.timeControlStatus = status + self.mirrorTimeControlStatus(status) self.startLiveJoinImmediatelyIfHolding(waitingReason: reason) // First .playing: re-sample route after 2.5s settle -- AVKit only negotiates HDMI format on playback start (issue #24). if status == .playing { self.hasEverPlayed = true } @@ -915,6 +910,16 @@ final class NativeAVPlayerHost { !surfaceEndFailures && hasEverPlayed } + /// Publishes AVPlayer's status and keeps `pausedSinceUptime` in step with it. + private func mirrorTimeControlStatus(_ status: AVPlayer.TimeControlStatus) { + if status != .paused { + pausedSinceUptime = nil + } else if pausedSinceUptime == nil { + pausedSinceUptime = DispatchTime.now().uptimeNanoseconds + } + timeControlStatus = status + } + /// The dead item's own `.paused` and its `failedToPlayToEndTime` land within a runloop turn of /// each other, in either order (the two are unsynchronized, see #50). A pause older than this /// was the viewer's. @@ -1756,7 +1761,7 @@ final class NativeAVPlayerHost { guard sessionID == sid else { return true } avPlayer.play() prematureEndRecoveryInFlight = false - timeControlStatus = avPlayer.timeControlStatus + mirrorTimeControlStatus(avPlayer.timeControlStatus) let resumedAt = await prematureEndReading().playhead EngineLog.emit( "[NativeAVPlayerHost] #\(sessionID) AE#287 resumed: rate=\(avPlayer.rate) " diff --git a/docs/architecture.md b/docs/architecture.md index 6a0fdae68..5c7d8bd9e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,7 +64,7 @@ What a host observes about a seek comes in two shapes. `isSeeking` / `seekTarget Restart latency is self-localizing (#93 follow-up): the "producer restarted" line carries a phase split (`stopWait/reopen/seek/build`), a producer's FIRST source read is timed, and any single `AVIOReader` read exceeding 2 s emits one `slow read` summary naming where the time went (detour fetches with network time, `connStallTimeout` waits, reconnects, backoff sleeps, bytes dropped by the stale-generation guard, generation span). A slow read with all-zero counters means the wait was upstream of the read loop. -A restart-window request must also never leave AVPlayer waiting in silence (#93 round 3): AVPlayer's media watchdog logs `-12889 "No response for media file"` after ~3.5 s without response HEADERS (holding the connection open does not help), and three strikes fail the item. A VOD serve still running at 2 s (`SlowServeSignal` armed by `VideoSegmentProvider.mediaSegment(at:onSlow:)`) therefore emits an early `200` with `Transfer-Encoding: chunked`; the segment follows as a single chunk when it lands, and a serve that ultimately misses aborts the connection (truncated transfer, AVPlayer retries) instead of framing a cacheable empty 200. Fast serves keep the byte-identical `Content-Length` response. If the item dies anyway, `failedToPlayToEndTime` parks it at rate 0 / `timeControlStatus == .paused` (with `item.status` often still `readyToPlay`), which every pause-guarded recovery layer used to misread as user intent, making the session terminal. The host now counts loopback-path end failures (`endFailureCount`), and the engine confirms the death through the same deferred window as the `.failed` KVO, then reloads the item through the stage-2 chain with the pause guard bypassed, bounded by `ItemDeathReviveGate` (3 attempts per dead spot; playback progress or a user seek away restores the budget). The bypass only admits the dead item; it does not overrule the viewer. The host records when AVPlayer's own `timeControlStatus` went `.paused`, so a pause from the engine, AVKit, Control Center or PiP counts alike, and an item whose transport had already stopped before it died (`endFailureFollowedPause`) is reloaded paused at its anchor; the next Play resumes there. +A restart-window request must also never leave AVPlayer waiting in silence (#93 round 3): AVPlayer's media watchdog logs `-12889 "No response for media file"` after ~3.5 s without response HEADERS (holding the connection open does not help), and three strikes fail the item. A VOD serve still running at 2 s (`SlowServeSignal` armed by `VideoSegmentProvider.mediaSegment(at:onSlow:)`) therefore emits an early `200` with `Transfer-Encoding: chunked`; the segment follows as a single chunk when it lands, and a serve that ultimately misses aborts the connection (truncated transfer, AVPlayer retries) instead of framing a cacheable empty 200. Fast serves keep the byte-identical `Content-Length` response. If the item dies anyway, `failedToPlayToEndTime` parks it at rate 0 / `timeControlStatus == .paused` (with `item.status` often still `readyToPlay`), which every pause-guarded recovery layer used to misread as user intent, making the session terminal. The host now counts loopback-path end failures (`endFailureCount`), and the engine confirms the death through the same deferred window as the `.failed` KVO, then reloads the item through the stage-2 chain with the pause guard bypassed, bounded by `ItemDeathReviveGate` (3 attempts per dead spot; playback progress or a user seek away restores the budget). The bypass only admits the dead item; it does not overrule the viewer. The host records when AVPlayer's own `timeControlStatus` went `.paused`, so a pause made before the death counts whether it came from the engine, AVKit, Control Center or PiP, and an item whose transport had already stopped before it died (`endFailureFollowedPause`) is reloaded paused at its anchor; the next Play resumes there. During the three-second confirmation window, a Play or Pause through the engine decides and a Play from outside the engine resumes, but a pause from AVKit or Control Center is not observed: the dead item is already `.paused`, so AVPlayer reports no change. When a restart does run, it must reproduce segments on the SAME media timeline the continuous run gave them: the loopback's contract with AVPlayer is "static VOD server", and AVPlayer anchors fMP4 segments by their `tfdt`. Each restart allocates a fresh mp4 muxer, and movenc zero-bases a new instance's timeline by default, so a restart-produced segment used to carry `tfdt=0` while the playlist placed it at its plan offset: an implicit timeline discontinuity on every restart, papered over for plain playback but fatal to ancillary consumers (AVKit's legible renderer detaches mid-PiP, Sodalite#32; playhead/loaded-range decoupling, #93). The muxer therefore sets `movflags +frag_discont` with `avoid_negative_ts=disabled` so `tfdt` carries the producer's absolute output timestamps, the restart audio gate inherits the session shift (video shift rescaled) instead of snapping audio onto the video seam, and leading head-of-stream audio that would map below 0 is dropped (the muxer no longer absorbs negative timestamps). A restarted segment is byte-identical to its continuous twin modulo the per-muxer `mfhd` sequence number (pinned by `RestartTimelineContinuityTests` on a committed A/V fixture); on matroska sources, per-sample DTS synthesis after a demuxer seek scatters the DTS decomposition and boundary-frame membership by a frame or two, but presentation timestamps and `tfdt` anchoring stay epoch-invariant. Because `tfdt` carries `unsigned int(64)`, that same setting makes a negative output axis unrepresentable rather than merely unusual, so the published first timestamp is clamped at zero (AE#509): libavformat serves an MPEG-TS whose first DTS sits within 60 s of the 33-bit PTS wrap with every timestamp `2^33` ticks low (`AV_PTS_WRAP_SUB_OFFSET`), and a live join there published `baseMediaDecodeTime = 2^64 - |dts|` against a playlist starting at 0, which AVPlayer answers by fetching the whole window and placing none of it, with no error and no stall of its own. From 82c83718e66f906434680eb6fa25d3f513663f18 Mon Sep 17 00:00:00 2001 From: Neureka Date: Mon, 28 Sep 2026 20:37:18 -0700 Subject: [PATCH 05/11] fix(native): do not stamp a pause from the premature-end recovery's own read The recovery reads timeControlStatus right after its own play(), where .paused means the item has not rolled yet rather than that the viewer paused. Stamping it could make a later item death reload paused with nobody having paused. That read now only clears the stamp. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/AetherEngine/Native/NativeAVPlayerHost.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index 4080fb903..c06d25a98 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -1761,7 +1761,11 @@ final class NativeAVPlayerHost { guard sessionID == sid else { return true } avPlayer.play() prematureEndRecoveryInFlight = false - mirrorTimeControlStatus(avPlayer.timeControlStatus) + // Read right after the recovery's own play(): `.paused` here is the item not yet rolling, not + // a viewer pause, so it may clear the pause stamp but never set it. Later KVO keeps it current. + let status = avPlayer.timeControlStatus + if status != .paused { pausedSinceUptime = nil } + timeControlStatus = status let resumedAt = await prematureEndReading().playhead EngineLog.emit( "[NativeAVPlayerHost] #\(sessionID) AE#287 resumed: rate=\(avPlayer.rate) " From 1ecfe0ad5327cd7d031acc1acfb119a3340b5702 Mon Sep 17 00:00:00 2001 From: Neureka Date: Mon, 28 Sep 2026 20:46:52 -0700 Subject: [PATCH 06/11] fix(native): record an engine-routed pause without waiting for a status change Pausing a player that is already .paused, such as an item left parked after a premature-end recovery, changes no timeControlStatus, so the viewer's pause was never stamped and a later item death reloaded playing. The host's pause() and setRate(0) now stamp the pause themselves, and play() and a non-zero setRate clear it. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/AetherEngine/Native/NativeAVPlayerHost.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index c06d25a98..ffe502cbe 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -132,7 +132,8 @@ final class NativeAVPlayerHost { /// play, false for pause, nil for none. A viewer can press either while the engine confirms the /// death, and that press outranks the transport state the item died in. private(set) var transportCommandSinceEndFailure: Bool? - /// Uptime at which the mirrored `timeControlStatus` became `.paused`; nil while the transport rolls. + /// Uptime at which the transport stopped: set when the mirrored `timeControlStatus` becomes + /// `.paused` or an engine-routed pause lands, cleared when it rolls or the engine is told to play. private var pausedSinceUptime: UInt64? /// End of the last seekable time range (seconds); tracks the live edge for EVENT playlists. /// KVO mirror of `seekableTimeRanges`, NOT a live read: the getter is a sync XPC round-trip @@ -1688,6 +1689,7 @@ final class NativeAVPlayerHost { // Set intent before play() so readyToPlay observer can re-assert if the replaceCurrentItem swap swallowed it. playIntent = true transportCommandSinceEndFailure = true + pausedSinceUptime = nil // Call play() immediately (no defer-until-ready): item.status never advances past .unknown until AVPlayer is told to play. avPlayer.play() } @@ -1695,6 +1697,9 @@ final class NativeAVPlayerHost { func pause() { playIntent = false transportCommandSinceEndFailure = false + // Stamped here as well as from KVO: pausing a player that is already `.paused` (an item parked + // after a recovery) changes no status, and the viewer's pause must still count. + if pausedSinceUptime == nil { pausedSinceUptime = DispatchTime.now().uptimeNanoseconds } avPlayer.pause() } @@ -1945,6 +1950,11 @@ final class NativeAVPlayerHost { // Non-zero rate counts as play intent (must survive replaceCurrentItem swap like play() does). playIntent = (value != 0) transportCommandSinceEndFailure = (value != 0) + if value != 0 { + pausedSinceUptime = nil + } else if pausedSinceUptime == nil { + pausedSinceUptime = DispatchTime.now().uptimeNanoseconds + } // #436: `play()` is rate 1.0 by definition, and it is re-issued from paths no client can see: // the readyToPlay re-assert after an item swap, interruption and background resume, the #287 // premature-end recovery, plus AVKit's own transport and the remote command centre calling From e5776757cd9c49e7ba155fa1d2942593e6b5f388 Mon Sep 17 00:00:00 2001 From: Neureka Date: Mon, 28 Sep 2026 20:47:04 -0700 Subject: [PATCH 07/11] docs(architecture): state how the item-death reload learns of a pause Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 5c7d8bd9e..115ca3c93 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,7 +64,7 @@ What a host observes about a seek comes in two shapes. `isSeeking` / `seekTarget Restart latency is self-localizing (#93 follow-up): the "producer restarted" line carries a phase split (`stopWait/reopen/seek/build`), a producer's FIRST source read is timed, and any single `AVIOReader` read exceeding 2 s emits one `slow read` summary naming where the time went (detour fetches with network time, `connStallTimeout` waits, reconnects, backoff sleeps, bytes dropped by the stale-generation guard, generation span). A slow read with all-zero counters means the wait was upstream of the read loop. -A restart-window request must also never leave AVPlayer waiting in silence (#93 round 3): AVPlayer's media watchdog logs `-12889 "No response for media file"` after ~3.5 s without response HEADERS (holding the connection open does not help), and three strikes fail the item. A VOD serve still running at 2 s (`SlowServeSignal` armed by `VideoSegmentProvider.mediaSegment(at:onSlow:)`) therefore emits an early `200` with `Transfer-Encoding: chunked`; the segment follows as a single chunk when it lands, and a serve that ultimately misses aborts the connection (truncated transfer, AVPlayer retries) instead of framing a cacheable empty 200. Fast serves keep the byte-identical `Content-Length` response. If the item dies anyway, `failedToPlayToEndTime` parks it at rate 0 / `timeControlStatus == .paused` (with `item.status` often still `readyToPlay`), which every pause-guarded recovery layer used to misread as user intent, making the session terminal. The host now counts loopback-path end failures (`endFailureCount`), and the engine confirms the death through the same deferred window as the `.failed` KVO, then reloads the item through the stage-2 chain with the pause guard bypassed, bounded by `ItemDeathReviveGate` (3 attempts per dead spot; playback progress or a user seek away restores the budget). The bypass only admits the dead item; it does not overrule the viewer. The host records when AVPlayer's own `timeControlStatus` went `.paused`, so a pause made before the death counts whether it came from the engine, AVKit, Control Center or PiP, and an item whose transport had already stopped before it died (`endFailureFollowedPause`) is reloaded paused at its anchor; the next Play resumes there. During the three-second confirmation window, a Play or Pause through the engine decides and a Play from outside the engine resumes, but a pause from AVKit or Control Center is not observed: the dead item is already `.paused`, so AVPlayer reports no change. +A restart-window request must also never leave AVPlayer waiting in silence (#93 round 3): AVPlayer's media watchdog logs `-12889 "No response for media file"` after ~3.5 s without response HEADERS (holding the connection open does not help), and three strikes fail the item. A VOD serve still running at 2 s (`SlowServeSignal` armed by `VideoSegmentProvider.mediaSegment(at:onSlow:)`) therefore emits an early `200` with `Transfer-Encoding: chunked`; the segment follows as a single chunk when it lands, and a serve that ultimately misses aborts the connection (truncated transfer, AVPlayer retries) instead of framing a cacheable empty 200. Fast serves keep the byte-identical `Content-Length` response. If the item dies anyway, `failedToPlayToEndTime` parks it at rate 0 / `timeControlStatus == .paused` (with `item.status` often still `readyToPlay`), which every pause-guarded recovery layer used to misread as user intent, making the session terminal. The host now counts loopback-path end failures (`endFailureCount`), and the engine confirms the death through the same deferred window as the `.failed` KVO, then reloads the item through the stage-2 chain with the pause guard bypassed, bounded by `ItemDeathReviveGate` (3 attempts per dead spot; playback progress or a user seek away restores the budget). The bypass only admits the dead item; it does not overrule the viewer. The host records when AVPlayer's own `timeControlStatus` went `.paused`, so a pause made before the death counts whether it came from the engine, AVKit, Control Center or PiP, and an item whose transport had already stopped before it died (`endFailureFollowedPause`) is reloaded paused at its anchor; the next Play resumes there. An engine-routed pause is stamped directly; a pause from AVKit or Control Center is seen only through that status change. During the three-second confirmation window, a Play or Pause through the engine decides and a Play from outside the engine resumes. A pause from AVKit or Control Center on a player that is already `.paused` (the dead item in that window, or an item left parked by a recovery) is not observed, because AVPlayer reports no change. When a restart does run, it must reproduce segments on the SAME media timeline the continuous run gave them: the loopback's contract with AVPlayer is "static VOD server", and AVPlayer anchors fMP4 segments by their `tfdt`. Each restart allocates a fresh mp4 muxer, and movenc zero-bases a new instance's timeline by default, so a restart-produced segment used to carry `tfdt=0` while the playlist placed it at its plan offset: an implicit timeline discontinuity on every restart, papered over for plain playback but fatal to ancillary consumers (AVKit's legible renderer detaches mid-PiP, Sodalite#32; playhead/loaded-range decoupling, #93). The muxer therefore sets `movflags +frag_discont` with `avoid_negative_ts=disabled` so `tfdt` carries the producer's absolute output timestamps, the restart audio gate inherits the session shift (video shift rescaled) instead of snapping audio onto the video seam, and leading head-of-stream audio that would map below 0 is dropped (the muxer no longer absorbs negative timestamps). A restarted segment is byte-identical to its continuous twin modulo the per-muxer `mfhd` sequence number (pinned by `RestartTimelineContinuityTests` on a committed A/V fixture); on matroska sources, per-sample DTS synthesis after a demuxer seek scatters the DTS decomposition and boundary-frame membership by a frame or two, but presentation timestamps and `tfdt` anchoring stay epoch-invariant. Because `tfdt` carries `unsigned int(64)`, that same setting makes a negative output axis unrepresentable rather than merely unusual, so the published first timestamp is clamped at zero (AE#509): libavformat serves an MPEG-TS whose first DTS sits within 60 s of the 33-bit PTS wrap with every timestamp `2^33` ticks low (`AV_PTS_WRAP_SUB_OFFSET`), and a live join there published `baseMediaDecodeTime = 2^64 - |dts|` against a playlist starting at 0, which AVPlayer answers by fetching the whole window and placing none of it, with no error and no stall of its own. From e6246ab435bd998dd1052e732ee43bb0aae6f980 Mon Sep 17 00:00:00 2001 From: Neureka Date: Mon, 28 Sep 2026 20:56:53 -0700 Subject: [PATCH 08/11] fix(native): read the viewer's pause from AVPlayer's rate timeControlStatus reports whether the item rolls, not what it was told: a Play from AVKit, Control Center or PiP on a dead or parked item changes nothing there, so an older pause stamp survived it and a later death reloaded paused. The pause stamp now follows AVPlayer's rate, which any play or pause sets even when the item cannot roll, and the reload's "rolling again" check reads the rate too. The status path and the premature-end recovery no longer touch the stamp; engine-routed pause() and setRate(0) still stamp directly for a rate that is already 0. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../AetherEngine/AetherEngine+Loading.swift | 2 +- .../Native/NativeAVPlayerHost.swift | 44 ++++++++----------- docs/architecture.md | 2 +- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index f17e90614..8644beded 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -1591,7 +1591,7 @@ extension AetherEngine { let resumesPlaying = NativeAVPlayerHost.itemDeathReloadResumesPlaying( diedUnderPause: diedUnderPause, commandSinceFailure: host.transportCommandSinceEndFailure, - transportRolling: host.timeControlStatus != .paused) + transportRolling: host.rate != 0) EngineLog.emit( "[AetherEngine] #93 item death (failedToPlayToEndTime) at " + "\(String(format: "%.2f", position))s; reloading item through stage-2 " diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index ffe502cbe..7b9ce69bf 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -132,8 +132,9 @@ final class NativeAVPlayerHost { /// play, false for pause, nil for none. A viewer can press either while the engine confirms the /// death, and that press outranks the transport state the item died in. private(set) var transportCommandSinceEndFailure: Bool? - /// Uptime at which the transport stopped: set when the mirrored `timeControlStatus` becomes - /// `.paused` or an engine-routed pause lands, cleared when it rolls or the engine is told to play. + /// Uptime at which the commanded transport stopped: stamped when AVPlayer's `rate` drops to 0 or an + /// engine-routed pause lands, cleared by any non-zero rate. The rate is what play and pause set, + /// from any source, even on an item that cannot roll; `timeControlStatus` only reports the outcome. private var pausedSinceUptime: UInt64? /// End of the last seekable time range (seconds); tracks the live edge for EVENT playlists. /// KVO mirror of `seekableTimeRanges`, NOT a live read: the getter is a sync XPC round-trip @@ -651,6 +652,7 @@ final class NativeAVPlayerHost { Task { @MainActor in guard let self, self.sessionID == sid else { return } self.rate = rate + self.stampTransport(rolling: rate != 0) } } @@ -680,7 +682,7 @@ final class NativeAVPlayerHost { guard let self, self.sessionID == sid else { return } // AE#287: swallow the pause AVPlayer takes while a premature-end recovery re-seeks. if status == .paused, self.prematureEndRecoveryInFlight { return } - self.mirrorTimeControlStatus(status) + self.timeControlStatus = status self.startLiveJoinImmediatelyIfHolding(waitingReason: reason) // First .playing: re-sample route after 2.5s settle -- AVKit only negotiates HDMI format on playback start (issue #24). if status == .playing { self.hasEverPlayed = true } @@ -911,24 +913,24 @@ final class NativeAVPlayerHost { !surfaceEndFailures && hasEverPlayed } - /// Publishes AVPlayer's status and keeps `pausedSinceUptime` in step with it. - private func mirrorTimeControlStatus(_ status: AVPlayer.TimeControlStatus) { - if status != .paused { + /// Keeps `pausedSinceUptime` on the commanded transport: cleared when it rolls, stamped once when + /// it stops and left alone while it stays stopped. + private func stampTransport(rolling: Bool) { + if rolling { pausedSinceUptime = nil } else if pausedSinceUptime == nil { pausedSinceUptime = DispatchTime.now().uptimeNanoseconds } - timeControlStatus = status } - /// The dead item's own `.paused` and its `failedToPlayToEndTime` land within a runloop turn of - /// each other, in either order (the two are unsynchronized, see #50). A pause older than this + /// The dead item's own drop to rate 0 and its `failedToPlayToEndTime` land within a runloop turn + /// of each other, in either order (the two are unsynchronized, see #50). A stop older than this /// was the viewer's. nonisolated static let pausedBeforeFailureMarginSeconds: Double = 1.0 /// Pure decision: had the transport already stopped when the item died? Read from AVPlayer's - /// own `timeControlStatus` rather than the #122 intent latch, because AVKit's transport bar, - /// Control Center and PiP pause and resume the player without passing through the engine. + /// own `rate` rather than the #122 intent latch, because AVKit's transport bar, Control Center + /// and PiP pause and resume the player without passing through the engine. nonisolated static func transportPausedBeforeFailure( pausedSinceUptime: UInt64?, failureUptime: UInt64 ) -> Bool { @@ -1689,7 +1691,7 @@ final class NativeAVPlayerHost { // Set intent before play() so readyToPlay observer can re-assert if the replaceCurrentItem swap swallowed it. playIntent = true transportCommandSinceEndFailure = true - pausedSinceUptime = nil + stampTransport(rolling: true) // Call play() immediately (no defer-until-ready): item.status never advances past .unknown until AVPlayer is told to play. avPlayer.play() } @@ -1697,9 +1699,9 @@ final class NativeAVPlayerHost { func pause() { playIntent = false transportCommandSinceEndFailure = false - // Stamped here as well as from KVO: pausing a player that is already `.paused` (an item parked - // after a recovery) changes no status, and the viewer's pause must still count. - if pausedSinceUptime == nil { pausedSinceUptime = DispatchTime.now().uptimeNanoseconds } + // Stamped here as well as from the rate KVO: pausing a player whose rate is already 0 (a dead + // or parked item) changes nothing AVPlayer reports, and the viewer's pause must still count. + stampTransport(rolling: false) avPlayer.pause() } @@ -1766,11 +1768,7 @@ final class NativeAVPlayerHost { guard sessionID == sid else { return true } avPlayer.play() prematureEndRecoveryInFlight = false - // Read right after the recovery's own play(): `.paused` here is the item not yet rolling, not - // a viewer pause, so it may clear the pause stamp but never set it. Later KVO keeps it current. - let status = avPlayer.timeControlStatus - if status != .paused { pausedSinceUptime = nil } - timeControlStatus = status + timeControlStatus = avPlayer.timeControlStatus let resumedAt = await prematureEndReading().playhead EngineLog.emit( "[NativeAVPlayerHost] #\(sessionID) AE#287 resumed: rate=\(avPlayer.rate) " @@ -1950,11 +1948,7 @@ final class NativeAVPlayerHost { // Non-zero rate counts as play intent (must survive replaceCurrentItem swap like play() does). playIntent = (value != 0) transportCommandSinceEndFailure = (value != 0) - if value != 0 { - pausedSinceUptime = nil - } else if pausedSinceUptime == nil { - pausedSinceUptime = DispatchTime.now().uptimeNanoseconds - } + stampTransport(rolling: value != 0) // #436: `play()` is rate 1.0 by definition, and it is re-issued from paths no client can see: // the readyToPlay re-assert after an item swap, interruption and background resume, the #287 // premature-end recovery, plus AVKit's own transport and the remote command centre calling diff --git a/docs/architecture.md b/docs/architecture.md index 115ca3c93..2685ea960 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,7 +64,7 @@ What a host observes about a seek comes in two shapes. `isSeeking` / `seekTarget Restart latency is self-localizing (#93 follow-up): the "producer restarted" line carries a phase split (`stopWait/reopen/seek/build`), a producer's FIRST source read is timed, and any single `AVIOReader` read exceeding 2 s emits one `slow read` summary naming where the time went (detour fetches with network time, `connStallTimeout` waits, reconnects, backoff sleeps, bytes dropped by the stale-generation guard, generation span). A slow read with all-zero counters means the wait was upstream of the read loop. -A restart-window request must also never leave AVPlayer waiting in silence (#93 round 3): AVPlayer's media watchdog logs `-12889 "No response for media file"` after ~3.5 s without response HEADERS (holding the connection open does not help), and three strikes fail the item. A VOD serve still running at 2 s (`SlowServeSignal` armed by `VideoSegmentProvider.mediaSegment(at:onSlow:)`) therefore emits an early `200` with `Transfer-Encoding: chunked`; the segment follows as a single chunk when it lands, and a serve that ultimately misses aborts the connection (truncated transfer, AVPlayer retries) instead of framing a cacheable empty 200. Fast serves keep the byte-identical `Content-Length` response. If the item dies anyway, `failedToPlayToEndTime` parks it at rate 0 / `timeControlStatus == .paused` (with `item.status` often still `readyToPlay`), which every pause-guarded recovery layer used to misread as user intent, making the session terminal. The host now counts loopback-path end failures (`endFailureCount`), and the engine confirms the death through the same deferred window as the `.failed` KVO, then reloads the item through the stage-2 chain with the pause guard bypassed, bounded by `ItemDeathReviveGate` (3 attempts per dead spot; playback progress or a user seek away restores the budget). The bypass only admits the dead item; it does not overrule the viewer. The host records when AVPlayer's own `timeControlStatus` went `.paused`, so a pause made before the death counts whether it came from the engine, AVKit, Control Center or PiP, and an item whose transport had already stopped before it died (`endFailureFollowedPause`) is reloaded paused at its anchor; the next Play resumes there. An engine-routed pause is stamped directly; a pause from AVKit or Control Center is seen only through that status change. During the three-second confirmation window, a Play or Pause through the engine decides and a Play from outside the engine resumes. A pause from AVKit or Control Center on a player that is already `.paused` (the dead item in that window, or an item left parked by a recovery) is not observed, because AVPlayer reports no change. +A restart-window request must also never leave AVPlayer waiting in silence (#93 round 3): AVPlayer's media watchdog logs `-12889 "No response for media file"` after ~3.5 s without response HEADERS (holding the connection open does not help), and three strikes fail the item. A VOD serve still running at 2 s (`SlowServeSignal` armed by `VideoSegmentProvider.mediaSegment(at:onSlow:)`) therefore emits an early `200` with `Transfer-Encoding: chunked`; the segment follows as a single chunk when it lands, and a serve that ultimately misses aborts the connection (truncated transfer, AVPlayer retries) instead of framing a cacheable empty 200. Fast serves keep the byte-identical `Content-Length` response. If the item dies anyway, `failedToPlayToEndTime` parks it at rate 0 / `timeControlStatus == .paused` (with `item.status` often still `readyToPlay`), which every pause-guarded recovery layer used to misread as user intent, making the session terminal. The host now counts loopback-path end failures (`endFailureCount`), and the engine confirms the death through the same deferred window as the `.failed` KVO, then reloads the item through the stage-2 chain with the pause guard bypassed, bounded by `ItemDeathReviveGate` (3 attempts per dead spot; playback progress or a user seek away restores the budget). The bypass only admits the dead item; it does not overrule the viewer. The host records when AVPlayer's own `rate` dropped to 0, so a pause made before the death counts whether it came from the engine, AVKit, Control Center or PiP, and an item whose transport had already stopped before it died (`endFailureFollowedPause`) is reloaded paused at its anchor; the next Play resumes there. An engine-routed pause is stamped directly; a pause from AVKit or Control Center is seen only through the rate change. During the three-second confirmation window, a Play or Pause through the engine decides and a Play from outside the engine (a non-zero rate) resumes. A pause from AVKit or Control Center on a player whose rate is already 0 (the dead item in that window, or an item left stopped by a recovery) is not observed, because AVPlayer reports no change. When a restart does run, it must reproduce segments on the SAME media timeline the continuous run gave them: the loopback's contract with AVPlayer is "static VOD server", and AVPlayer anchors fMP4 segments by their `tfdt`. Each restart allocates a fresh mp4 muxer, and movenc zero-bases a new instance's timeline by default, so a restart-produced segment used to carry `tfdt=0` while the playlist placed it at its plan offset: an implicit timeline discontinuity on every restart, papered over for plain playback but fatal to ancillary consumers (AVKit's legible renderer detaches mid-PiP, Sodalite#32; playhead/loaded-range decoupling, #93). The muxer therefore sets `movflags +frag_discont` with `avoid_negative_ts=disabled` so `tfdt` carries the producer's absolute output timestamps, the restart audio gate inherits the session shift (video shift rescaled) instead of snapping audio onto the video seam, and leading head-of-stream audio that would map below 0 is dropped (the muxer no longer absorbs negative timestamps). A restarted segment is byte-identical to its continuous twin modulo the per-muxer `mfhd` sequence number (pinned by `RestartTimelineContinuityTests` on a committed A/V fixture); on matroska sources, per-sample DTS synthesis after a demuxer seek scatters the DTS decomposition and boundary-frame membership by a frame or two, but presentation timestamps and `tfdt` anchoring stay epoch-invariant. Because `tfdt` carries `unsigned int(64)`, that same setting makes a negative output axis unrepresentable rather than merely unusual, so the published first timestamp is clamped at zero (AE#509): libavformat serves an MPEG-TS whose first DTS sits within 60 s of the 33-bit PTS wrap with every timestamp `2^33` ticks low (`AV_PTS_WRAP_SUB_OFFSET`), and a live join there published `baseMediaDecodeTime = 2^64 - |dts|` against a playlist starting at 0, which AVPlayer answers by fetching the whole window and placing none of it, with no error and no stall of its own. From 135bee711bae383f29d953435ed58eb893e19882 Mon Sep 17 00:00:00 2001 From: Neureka Date: Mon, 28 Sep 2026 21:08:47 -0700 Subject: [PATCH 09/11] fix(native): keep premature-end recovery stops out of the pause stamp The premature end drops AVPlayer's rate to 0 before the recovery starts, and the re-seek can drop it again. Neither is a viewer pause, but both stamped one, so an item death more than a second later reloaded paused. Rate drops during the recovery are now ignored, and the recovery clears the stamp once it has commanded play. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/AetherEngine/Native/NativeAVPlayerHost.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index 7b9ce69bf..2bbf4748d 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -652,7 +652,10 @@ final class NativeAVPlayerHost { Task { @MainActor in guard let self, self.sessionID == sid else { return } self.rate = rate - self.stampTransport(rolling: rate != 0) + // AE#287: a stop inside the premature-end re-seek is the recovery's, not the viewer's. + if rate != 0 || !self.prematureEndRecoveryInFlight { + self.stampTransport(rolling: rate != 0) + } } } @@ -1768,6 +1771,9 @@ final class NativeAVPlayerHost { guard sessionID == sid else { return true } avPlayer.play() prematureEndRecoveryInFlight = false + // The premature end stopped the rate before the recovery began, and nobody paused: the + // recovery has now commanded play, so drop that stamp even if AVPlayer's rate has not moved. + stampTransport(rolling: true) timeControlStatus = avPlayer.timeControlStatus let resumedAt = await prematureEndReading().playhead EngineLog.emit( From 5d09a902c2b12851aa5975c7bb3242719b94df98 Mon Sep 17 00:00:00 2001 From: Neureka Date: Mon, 28 Sep 2026 21:16:07 -0700 Subject: [PATCH 10/11] fix(native): attribute recovery stops by report time and keep a pause through it The rate observer judged a stop against the premature-end recovery flag when its main-actor hop ran, which can be after the recovery ended, so a recovery-owned stop could still be stamped as a viewer pause. It now compares when AVPlayer reported the change with when the recovery handed transport back. The recovery also resumed after its re-seek even when the viewer had paused through the engine meanwhile, and cleared the pause stamp. It now stays paused when the play intent was cleared during the re-seek. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Native/NativeAVPlayerHost.swift | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index 2bbf4748d..9b32583a7 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -114,6 +114,9 @@ final class NativeAVPlayerHost { /// duration, and publishing that transient would bounce the engine through `.paused` and back for /// what the viewer must not even notice; the real status is republished when the recovery settles. private var prematureEndRecoveryInFlight = false + /// Uptime at which the last premature-end recovery handed transport back. A rate change AVPlayer + /// reported before it belongs to that recovery, even when its main-actor hop runs later. + private var prematureEndRecoveryEndedUptime: UInt64 = 0 /// Mirrors avPlayer.timeControlStatus so the engine can reconcile when AVKit's transport bar, Control Center, or hardware buttons toggle the player externally (without this, engine state goes stale and play/pause presses are swallowed). @Published private(set) var timeControlStatus: AVPlayer.TimeControlStatus = .paused /// Monotonic count of AVPlayerItem playbackStalled notifications (#93 residual): the engine @@ -648,12 +651,17 @@ final class NativeAVPlayerHost { rateObservation = avPlayer.observe(\.rate, options: [.new]) { [weak self] player, _ in let rate = player.rate + let observedAt = DispatchTime.now().uptimeNanoseconds EngineLog.emit("[NativeAVPlayerHost] #\(sid) rate=\(rate)", category: .engine) Task { @MainActor in guard let self, self.sessionID == sid else { return } self.rate = rate - // AE#287: a stop inside the premature-end re-seek is the recovery's, not the viewer's. - if rate != 0 || !self.prematureEndRecoveryInFlight { + // AE#287: a stop AVPlayer reported during the premature-end re-seek is the recovery's, + // not the viewer's. Judged by when AVPlayer reported it: this hop can run after the + // recovery has ended. + let recoveryOwned = self.prematureEndRecoveryInFlight + || observedAt <= self.prematureEndRecoveryEndedUptime + if rate != 0 || !recoveryOwned { self.stampTransport(rolling: rate != 0) } } @@ -1769,8 +1777,19 @@ final class NativeAVPlayerHost { // The session may have been handed over while the seek was in flight; a retired session // must not restart the player under its successor. guard sessionID == sid else { return true } + // A viewer who paused through the engine while the re-seek was in flight keeps the pause. + guard playIntent else { + prematureEndRecoveryInFlight = false + prematureEndRecoveryEndedUptime = DispatchTime.now().uptimeNanoseconds + timeControlStatus = avPlayer.timeControlStatus + EngineLog.emit( + "[NativeAVPlayerHost] #\(sessionID) AE#287 re-seeked; staying paused for the viewer", + category: .engine) + return true + } avPlayer.play() prematureEndRecoveryInFlight = false + prematureEndRecoveryEndedUptime = DispatchTime.now().uptimeNanoseconds // The premature end stopped the rate before the recovery began, and nobody paused: the // recovery has now commanded play, so drop that stamp even if AVPlayer's rate has not moved. stampTransport(rolling: true) From 39d5e1484138d436403bd9879215232ae137e89b Mon Sep 17 00:00:00 2001 From: Neureka Date: Mon, 28 Sep 2026 21:23:03 -0700 Subject: [PATCH 11/11] fix(native): apply transport stamps in the order AVPlayer reported them Rate reports reach the main actor after engine commands issued later, so a stale rolling report could clear a newer engine pause, and a pause reported before a premature-end recovery began could be attributed to the recovery when its hop ran afterwards. The pause stamp now takes the time each event happened and ignores events older than the last one applied, and a recovery only owns reports inside its start-to-end interval. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Native/NativeAVPlayerHost.swift | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index 9b32583a7..f07064031 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -114,9 +114,13 @@ final class NativeAVPlayerHost { /// duration, and publishing that transient would bounce the engine through `.paused` and back for /// what the viewer must not even notice; the real status is republished when the recovery settles. private var prematureEndRecoveryInFlight = false - /// Uptime at which the last premature-end recovery handed transport back. A rate change AVPlayer - /// reported before it belongs to that recovery, even when its main-actor hop runs later. + /// Uptimes bounding the last premature-end recovery. A rate change AVPlayer reported inside that + /// interval belongs to the recovery, even when its main-actor hop runs after the recovery ended. + private var prematureEndRecoveryStartedUptime: UInt64 = 0 private var prematureEndRecoveryEndedUptime: UInt64 = 0 + /// Uptime of the newest transport event applied to `pausedSinceUptime`. Rate reports reach the + /// main actor after engine commands issued later, so an older report must not overwrite them. + private var transportStampEventUptime: UInt64 = 0 /// Mirrors avPlayer.timeControlStatus so the engine can reconcile when AVKit's transport bar, Control Center, or hardware buttons toggle the player externally (without this, engine state goes stale and play/pause presses are swallowed). @Published private(set) var timeControlStatus: AVPlayer.TimeControlStatus = .paused /// Monotonic count of AVPlayerItem playbackStalled notifications (#93 residual): the engine @@ -659,10 +663,10 @@ final class NativeAVPlayerHost { // AE#287: a stop AVPlayer reported during the premature-end re-seek is the recovery's, // not the viewer's. Judged by when AVPlayer reported it: this hop can run after the // recovery has ended. - let recoveryOwned = self.prematureEndRecoveryInFlight - || observedAt <= self.prematureEndRecoveryEndedUptime + let recoveryOwned = observedAt >= self.prematureEndRecoveryStartedUptime + && (self.prematureEndRecoveryInFlight || observedAt <= self.prematureEndRecoveryEndedUptime) if rate != 0 || !recoveryOwned { - self.stampTransport(rolling: rate != 0) + self.stampTransport(rolling: rate != 0, at: observedAt) } } } @@ -926,11 +930,13 @@ final class NativeAVPlayerHost { /// Keeps `pausedSinceUptime` on the commanded transport: cleared when it rolls, stamped once when /// it stops and left alone while it stays stopped. - private func stampTransport(rolling: Bool) { + private func stampTransport(rolling: Bool, at uptime: UInt64 = DispatchTime.now().uptimeNanoseconds) { + guard uptime >= transportStampEventUptime else { return } + transportStampEventUptime = uptime if rolling { pausedSinceUptime = nil } else if pausedSinceUptime == nil { - pausedSinceUptime = DispatchTime.now().uptimeNanoseconds + pausedSinceUptime = uptime } } @@ -1764,6 +1770,7 @@ final class NativeAVPlayerHost { prematureEndRecoveryAttempts += 1 lastPrematureEndRecoveryPlayhead = playhead prematureEndRecoveryInFlight = true + prematureEndRecoveryStartedUptime = DispatchTime.now().uptimeNanoseconds EngineLog.emit( "[NativeAVPlayerHost] #\(sessionID) AE#287 premature end: playhead=" + "\(String(format: "%.3f", playhead))s duration=\(String(format: "%.3f", duration))s "