From 6701873ab770bf052ecab889a133a8f16ab5da61 Mon Sep 17 00:00:00 2001 From: Johan Marais Date: Wed, 7 Oct 2026 06:28:43 +0200 Subject: [PATCH 1/4] fix(sw): stop repositioning a forward-only source on the software path A forward-only source (one chunked response with no ranges, a sequential origin, a one-shot custom reader) has one read position. Three paths tried to move it, and each ended the session: the reposition flushes the packets the demuxer has queued, the reader cannot rewind to read them again, libavformat reports "partial file", and the read loop stops. - load with a start position: the software host no longer seeks. A start inside the clock anchor's 2 s tolerance is reached by decoding forward from the stream origin; a later one is dropped and playback begins at the origin. A host resuming a server-seeked progressive stream hit the seek on every resume, because the server starts the stream at the preceding keyframe and asks the player to skip the pre-roll. - seek: rejected at the engine with the new SeekEvent.Rejection.sourceNotSeekable, before the target is clamped to the container's duration or published. The host refuses too, before any decoder or queue is disturbed. - play: no rewind to zero. A fragmented MP4 served as one response reports its first fragment as the duration, so the playhead read as parked at the end and the first play after a pause ended playback. Seekable and live sources are unchanged. The audio-only host still repositions a forward-only source. Refs Silo-Server/silo-apple#656 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + Sources/AetherEngine/AetherEngine.swift | 30 ++++++- .../Native/SoftwarePlaybackHost.swift | 86 +++++++++++++++++-- Sources/AetherEngine/SeekEvent.swift | 3 + .../SeekToEndOfMediaTests.swift | 14 +++ .../SoftwareHostForwardOnlyStartTests.swift | 68 +++++++++++++++ 6 files changed, 191 insertions(+), 11 deletions(-) create mode 100644 Tests/AetherEngineTests/SoftwareHostForwardOnlyStartTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c91fd1c81..45475321a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ the public-API contract. ### Fixed +- A forward-only source (one chunked response with no ranges, a sequential origin, a one-shot custom reader) is no longer repositioned on the software video path, which ended the session each time: the reposition flushed the demuxer's queued packets and the reader could not rewind to read them again (`partial file`). A start position within 2 s is now reached by decoding forward from the stream origin, and a later one is dropped so playback begins at the origin; `seek(to:)` is rejected with the new `SeekEvent.Rejection.sourceNotSeekable` and playback continues; and `play()` no longer rewinds such a source to zero, which it did whenever the container reported a duration shorter than the stream, as a fragmented MP4 served as one response does. Seekable and live sources are unchanged. The audio-only host still repositions a forward-only source. - The software video decoder no longer runs more than 16 frame threads. It used one per core, and each frame thread holds back one decoded frame, so a 32-core Mac waited for 31 frames before showing the first one after a load or seek (about 3 s at 10 fps). FFmpeg also warns above 16 threads. Hosts with 16 or fewer cores keep their current thread count. - 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. - A dead item's recovery no longer restarts the title from where the session was first opened. When AVPlayer refused the recovery item's master (`-11868`), the media fallback reloaded at the first mount's start position, so a title opened from its beginning restarted at 0:00. The fallback now reloads where the refused item was placed. Upstream #621. diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 03aecd5f4..e3746cd66 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -3210,13 +3210,25 @@ public final class AetherEngine: ObservableObject { /// `AVPlayer.play()` at end-of-media is a no-op, freezing the button. `.ended` is deliberately /// excluded: it is terminal (#63), the host revives it by reloading, and a play press racing the end /// card must not silently restart the finished session. + /// + /// A source that cannot be repositioned never rewinds. Its container can report a duration far + /// short of the stream (a fragmented MP4 served as one response reports its first fragment, a + /// few seconds), which reads as "parked at the end" for the rest of the session; the rewind is a + /// seek the source cannot serve, and it ended playback on the first play after a pause. nonisolated static func shouldRewindBeforePlay( - state: PlaybackState, currentTime: Double, duration: Double, isLive: Bool + state: PlaybackState, currentTime: Double, duration: Double, isLive: Bool, + sourceCanReposition: Bool = true ) -> Bool { - guard state != .ended else { return false } + guard state != .ended, sourceCanReposition else { return false } return isAtEndOfMedia(currentTime: currentTime, duration: duration, isLive: isLive) } + /// Whether a seek is refused because the loaded source cannot be repositioned. Live sources + /// keep their own (DVR window) rules. + nonisolated static func seekRefusedForForwardOnlySource(isLive: Bool, sourceCanReposition: Bool) -> Bool { + !isLive && !sourceCanReposition + } + #if DEBUG /// Test-only override for the session's restart-in-flight signal (#93 residual deferral tests). var testHookRestartInFlightOverride: Bool? = nil @@ -4828,7 +4840,8 @@ public final class AetherEngine: ObservableObject { // first, then resume. `.ended` is excluded (see shouldRewindBeforePlay): it stays terminal so a // play press racing the host end card does not silently revive a finished session (#63). if Self.shouldRewindBeforePlay( - state: state, currentTime: currentTime, duration: duration, isLive: isLive + state: state, currentTime: currentTime, duration: duration, isLive: isLive, + sourceCanReposition: softwareHost?.sourceCanReposition ?? true ) { Task { @MainActor in await self.seek(to: 0) @@ -5039,6 +5052,17 @@ public final class AetherEngine: ObservableObject { return } } + // A forward-only source on the software path has no other position to offer, and its + // container can report a duration far short of the stream. Forwarding the seek would + // clamp the target to that duration, publish it as the position and park the state, all + // for a reposition the host has to refuse. Reject it here and leave the session playing. + if Self.seekRefusedForForwardOnlySource( + isLive: isLive, sourceCanReposition: softwareHost?.sourceCanReposition ?? true + ) { + EngineLog.emit("[AetherEngine] seek(to:\(seconds)) ignored: the source is forward-only", category: .engine) + emitSeekRejected(.sourceNotSeekable, target: seconds) + return + } // #127: pre-ready native item (background-teardown reload, cold start): forwarding the seek now // would clamp to 0 against empty seekable ranges and replace load()'s pending startPosition seek. // Stash the latest target (publishing it optimistically so scrub UI follows) and replay at readiness. diff --git a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift index c45c3986f..321dfda00 100644 --- a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift +++ b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift @@ -706,6 +706,42 @@ final class SoftwarePlaybackHost { ) == .stop } + // MARK: - Start position + + /// How `load` honours a start position. + enum StartPlan: Equatable { + /// No start to honour: play from the stream origin. + case fromOrigin + /// Reposition the source to the start. + case reposition + /// Leave the source where it is and decode forward to the start, dropping what precedes it. + case decodeForward + /// The start cannot be reached; play from the stream origin. + case dropStart + } + + /// Furthest start a forward-only source is decoded forward to: the clock anchor's own tolerance. + /// + /// With no reposition the first audio sample is at the stream origin, and the clock is armed by + /// `SWClockAnchorPolicy` from that sample. Inside its tolerance the clock keeps the load anchor, + /// so it starts at `start`, the audio before it is late and discarded, and the video before it + /// is dropped by the skip threshold. Past the tolerance the policy re-anchors at the first + /// sample instead: the pre-roll audio would play with no picture and the position would snap + /// back to the origin. Such a start is dropped, and the pre-roll plays with its picture. + nonisolated static let forwardOnlyStartSkipLimitSeconds: Double = SWClockAnchorPolicy.toleranceSeconds + + /// A forward-only source (one chunked response with no ranges, a sequential origin, a one-shot + /// custom reader) cannot be repositioned, so its start is reached by decoding or not at all. + nonisolated static func startPlan(startPosition: Double?, isSourceSeekable: Bool, isLive: Bool) -> StartPlan { + guard let start = startPosition, start.isFinite, start > 0 else { return .fromOrigin } + // Live keeps what it had: this policy is about finite forward-only sources. + if isSourceSeekable || isLive { return .reposition } + return start <= forwardOnlyStartSkipLimitSeconds ? .decodeForward : .dropStart + } + + /// False when the loaded source cannot be repositioned (see `startPlan`). True with no source. + var sourceCanReposition: Bool { demuxer?.isSourceSeekable ?? true } + // MARK: - Load func load( @@ -914,14 +950,37 @@ final class SoftwarePlaybackHost { // Reset the live feeder state for the new session. resetFeederState() - if let start = startPosition, start > 0 { - // #254: same off-main, deadline-bounded reposition the transport seek uses. A resume into a - // remote source that has to scan for its landing would otherwise block the main thread here. - _ = await dem.seekBounded(to: start, timeout: Self.seekBudgetSeconds, on: seekQueue) - // This is load()'s only suspension point, so it is also the only place a stop() can land - // mid-load. Arming the clock and publishing isReady on a session already torn down would - // hand the engine a host it has stopped. - guard !stopRequested else { return } + let startPlan = Self.startPlan(startPosition: startPosition, + isSourceSeekable: dem.isSourceSeekable, isLive: isLive) + if startPlan == .dropStart, let start = startPosition { + EngineLog.emit( + "[SWHost] start at \(String(format: "%.2f", start))s dropped: the source is forward-only " + + "and the start is past the \(String(format: "%.0f", Self.forwardOnlyStartSkipLimitSeconds))s " + + "the clock anchor holds without a reposition; playing from the stream origin", + category: .swPlayback + ) + } + if let start = startPosition, startPlan == .reposition || startPlan == .decodeForward { + if startPlan == .reposition { + // #254: same off-main, deadline-bounded reposition the transport seek uses. A resume into a + // remote source that has to scan for its landing would otherwise block the main thread here. + _ = await dem.seekBounded(to: start, timeout: Self.seekBudgetSeconds, on: seekQueue) + // This is load()'s only suspension point, so it is also the only place a stop() can land + // mid-load. Arming the clock and publishing isReady on a session already torn down would + // hand the engine a host it has stopped. + guard !stopRequested else { return } + } else { + // The read position already stands at the stream origin and the packets the probe read + // are still queued. A seek here flushes them, and a forward-only reader cannot rewind to + // read them again (`partial file`, then the read loop ends). The thresholds armed below + // drop the video that precedes `start`, and the clock arms at `start` because it is + // inside the anchor tolerance (see `forwardOnlyStartSkipLimitSeconds`). + EngineLog.emit( + "[SWHost] start at \(String(format: "%.2f", start))s reached by decoding from the stream " + + "origin: the source is forward-only, so it is not repositioned", + category: .swPlayback + ) + } // Mirror seek() skip-PTS + clock alignment so demux drops pre-keyframe frames and synchronizer starts at the resume offset. let startTime = CMTime(seconds: start, preferredTimescale: 90000) videoDecoder.skipUntilPTS = startTime @@ -1229,6 +1288,17 @@ final class SoftwarePlaybackHost { func seek(to seconds: Double) async -> Demuxer.RepositionOutcome { guard !stopRequested else { return .superseded } guard let dem = demuxer else { return .stalled } + // A forward-only source has no other position to offer. Attempting the reposition flushes + // both decoders and the demuxer's queued packets, and the reader cannot rewind to read them + // again, so the session dies on the next read. Refuse before anything is disturbed; the + // session keeps playing where it is. Live seeks stay on their own (DVR ring) path below. + if !isLive, !dem.isSourceSeekable { + EngineLog.emit( + "[SWHost] seek to \(String(format: "%.2f", seconds))s refused: the source is forward-only", + category: .swPlayback + ) + return .stalled + } // Stop loop + bump generation to invalidate in-flight packets. Captured right after, so the // reposition can tell on `seekQueue` whether a newer seek has already taken over. bumpSeekGeneration() diff --git a/Sources/AetherEngine/SeekEvent.swift b/Sources/AetherEngine/SeekEvent.swift index 3441b308a..e4f6aa466 100644 --- a/Sources/AetherEngine/SeekEvent.swift +++ b/Sources/AetherEngine/SeekEvent.swift @@ -41,6 +41,9 @@ public struct SeekEvent: Sendable, Equatable { case noActiveSession /// Live source without a DVR window; there is no seekable range to land in. case liveWithoutDVR + /// The source is forward-only (one response with no ranges, or a one-shot reader): it has + /// no other position to offer. Playback continues where it is. + case sourceNotSeekable } public enum Outcome: Sendable, Equatable { diff --git a/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift b/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift index ca1cd0ee0..29352d6da 100644 --- a/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift +++ b/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift @@ -81,6 +81,20 @@ struct SeekToEndOfMediaTests { #expect(!AetherEngine.shouldRewindBeforePlay(state: .paused, currentTime: 100, duration: 100, isLive: true)) } + @Test("A source that cannot be repositioned never rewinds, whatever its duration claims") + func forwardOnlySourceNeverRewinds() { + // A fragmented MP4 served as one response reports its first fragment as the duration: + // 3.6 s for a film with over an hour left. Twenty minutes in, that reads as parked at + // the end, and the rewind is a seek this source cannot serve. + #expect(!AetherEngine.shouldRewindBeforePlay( + state: .paused, currentTime: 1_196.7, duration: 3.6, isLive: false, sourceCanReposition: false)) + #expect(!AetherEngine.shouldRewindBeforePlay( + state: .paused, currentTime: 100, duration: 100, isLive: false, sourceCanReposition: false)) + // The same playhead on a source that can rewind still does. + #expect(AetherEngine.shouldRewindBeforePlay( + state: .paused, currentTime: 100, duration: 100, isLive: false, sourceCanReposition: true)) + } + // MARK: - Integration: real seek() / play() paths (no hosts) @MainActor diff --git a/Tests/AetherEngineTests/SoftwareHostForwardOnlyStartTests.swift b/Tests/AetherEngineTests/SoftwareHostForwardOnlyStartTests.swift new file mode 100644 index 000000000..0d258f268 --- /dev/null +++ b/Tests/AetherEngineTests/SoftwareHostForwardOnlyStartTests.swift @@ -0,0 +1,68 @@ +import Testing +@testable import AetherEngine + +/// How the software host honours a start position on a source it cannot reposition. +/// +/// A forward-only source (one chunked response with no ranges, a sequential origin, a one-shot +/// custom reader) has exactly one read position. Seeking it on load flushed the packets the probe +/// had read; the reader could not rewind to read them again, libavformat reported `partial file` +/// for the first sample, and the session reached `ready` and then never played. A host resuming a +/// server-seeked progressive stream hit this on every resume, because the server starts the +/// stream at the preceding keyframe and asks the player to skip the pre-roll. +@Suite("Software host start position on a forward-only source") +struct SoftwareHostForwardOnlyStartTests { + private typealias Host = SoftwarePlaybackHost + + @Test("No start, a zero start and a non-finite start all play from the origin") + func noStartPlaysFromOrigin() { + for start in [nil, 0, -3, Double.nan, Double.infinity] as [Double?] { + for seekable in [true, false] { + #expect(Host.startPlan(startPosition: start, isSourceSeekable: seekable, isLive: false) == .fromOrigin) + } + } + } + + @Test("A seekable source is repositioned to its start, however far") + func seekableSourceRepositions() { + #expect(Host.startPlan(startPosition: 2.8, isSourceSeekable: true, isLive: false) == .reposition) + #expect(Host.startPlan(startPosition: 5_000, isSourceSeekable: true, isLive: false) == .reposition) + } + + @Test("A forward-only source decodes forward to a start inside the limit and is never repositioned") + func forwardOnlySourceDecodesForward() { + #expect(Host.startPlan(startPosition: 0.8, isSourceSeekable: false, isLive: false) == .decodeForward) + #expect(Host.startPlan(startPosition: Host.forwardOnlyStartSkipLimitSeconds, + isSourceSeekable: false, isLive: false) == .decodeForward) + } + + @Test("The decode-forward limit is the clock anchor's tolerance") + func limitIsTheClockAnchorTolerance() { + // Past the tolerance the clock re-anchors at the first sample, which without a reposition + // is the stream origin: pre-roll audio with no picture. The two must move together. + let limit = Host.forwardOnlyStartSkipLimitSeconds + #expect(limit == SWClockAnchorPolicy.toleranceSeconds) + #expect(SWClockAnchorPolicy.resolve(initialSeconds: limit, firstSampleSeconds: 0).anchorSeconds == limit) + #expect(SWClockAnchorPolicy.resolve(initialSeconds: limit + 0.1, firstSampleSeconds: 0).anchorSeconds == 0) + } + + @Test("A forward-only source drops a start the clock anchor would not hold") + func forwardOnlySourceDropsAFarStart() { + #expect(Host.startPlan(startPosition: Host.forwardOnlyStartSkipLimitSeconds + 0.1, + isSourceSeekable: false, isLive: false) == .dropStart) + #expect(Host.startPlan(startPosition: 1_004.87, isSourceSeekable: false, isLive: false) == .dropStart) + } + + @Test("A seek is refused on a forward-only source, and never on a live or seekable one") + func seekRefusal() { + #expect(AetherEngine.seekRefusedForForwardOnlySource(isLive: false, sourceCanReposition: false)) + #expect(!AetherEngine.seekRefusedForForwardOnlySource(isLive: false, sourceCanReposition: true)) + #expect(!AetherEngine.seekRefusedForForwardOnlySource(isLive: true, sourceCanReposition: false)) + #expect(!AetherEngine.seekRefusedForForwardOnlySource(isLive: true, sourceCanReposition: true)) + } + + @Test("A live source keeps its existing start handling") + func liveKeepsItsStartHandling() { + #expect(Host.startPlan(startPosition: 40, isSourceSeekable: false, isLive: true) == .reposition) + #expect(Host.startPlan(startPosition: 40, isSourceSeekable: true, isLive: true) == .reposition) + } +} From aed4dde1b9dcbce0be90af6393d9cd4b4671a71c Mon Sep 17 00:00:00 2001 From: Johan Marais Date: Wed, 7 Oct 2026 08:48:03 +0200 Subject: [PATCH 2/4] fix(sw): close a stashed seek when the forward-only rejection refuses its replay A seek stashed during load is replayed once the session settles, with its deferred ticket still open. The forward-only rejection returned without closing it, so isSeeking stayed latched over a session that played on. Close the ticket with the rejection; a seek with no stash still gets the standalone event. Also say in the changelog that SeekEvent.Rejection.sourceNotSeekable is a new public case an exhaustive switch has to handle. Refs Silo-Server/silo-apple#656 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- Sources/AetherEngine/AetherEngine.swift | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45475321a..70d89619d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ the public-API contract. ### Fixed -- A forward-only source (one chunked response with no ranges, a sequential origin, a one-shot custom reader) is no longer repositioned on the software video path, which ended the session each time: the reposition flushed the demuxer's queued packets and the reader could not rewind to read them again (`partial file`). A start position within 2 s is now reached by decoding forward from the stream origin, and a later one is dropped so playback begins at the origin; `seek(to:)` is rejected with the new `SeekEvent.Rejection.sourceNotSeekable` and playback continues; and `play()` no longer rewinds such a source to zero, which it did whenever the container reported a duration shorter than the stream, as a fragmented MP4 served as one response does. Seekable and live sources are unchanged. The audio-only host still repositions a forward-only source. +- A forward-only source (one chunked response with no ranges, a sequential origin, a one-shot custom reader) is no longer repositioned on the software video path, which ended the session each time: the reposition flushed the demuxer's queued packets and the reader could not rewind to read them again (`partial file`). A start position within 2 s is now reached by decoding forward from the stream origin, and a later one is dropped so playback begins at the origin; `seek(to:)` is rejected with the new `SeekEvent.Rejection.sourceNotSeekable` and playback continues (a new public case: a host that switches exhaustively over `SeekEvent.Rejection` must add an arm for it); and `play()` no longer rewinds such a source to zero, which it did whenever the container reported a duration shorter than the stream, as a fragmented MP4 served as one response does. Seekable and live sources are unchanged. The audio-only host still repositions a forward-only source. - The software video decoder no longer runs more than 16 frame threads. It used one per core, and each frame thread holds back one decoded frame, so a 32-core Mac waited for 31 frames before showing the first one after a load or seek (about 3 s at 10 fps). FFmpeg also warns above 16 threads. Hosts with 16 or fewer cores keep their current thread count. - 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. - A dead item's recovery no longer restarts the title from where the session was first opened. When AVPlayer refused the recovery item's master (`-11868`), the media fallback reloaded at the first mount's start position, so a title opened from its beginning restarted at 0:00. The fallback now reloads where the refused item was placed. Upstream #621. diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index e3746cd66..894d4ec2a 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -5060,7 +5060,14 @@ public final class AetherEngine: ObservableObject { isLive: isLive, sourceCanReposition: softwareHost?.sourceCanReposition ?? true ) { EngineLog.emit("[AetherEngine] seek(to:\(seconds)) ignored: the source is forward-only", category: .engine) - emitSeekRejected(.sourceNotSeekable, target: seconds) + // A seek stashed during load is replayed here, and its ticket is still open. Close + // that ticket with the rejection, or `isSeeking` stays latched over a session that + // plays on. A seek with no stash gets the standalone event instead, never both. + if deferredSeekInFlight || deferredSeekTicket != nil { + endDeferredSeek(.rejected(.sourceNotSeekable)) + } else { + emitSeekRejected(.sourceNotSeekable, target: seconds) + } return } // #127: pre-ready native item (background-teardown reload, cold start): forwarding the seek now From 1774e8839ba56ab824ee38203013c4ea4cbd1676 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:24:38 -0400 Subject: [PATCH 3/4] fix(engine): play() resumes a forward-only source instead of rewinding it A fragmented MP4 served as one response reports its first fragment as the container duration. Past it the playhead reads as parked at the end, so play() asked for a rewind to 0, seek(to:) rejected it as sourceNotSeekable, and play() asked again: thousands of rejected seeks a second while playback stayed paused. shouldRewindBeforePlay now skips a source that cannot be repositioned. The rule comes from one place, sourceIsForwardOnly, which canSeek and seek(to:) also read, so the native sequential-origin path is covered as well as the software one. Refs Silo-Server/silo-apple#656 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + Sources/AetherEngine/AetherEngine.swift | 22 +++++++++++++---- .../SeekToEndOfMediaTests.swift | 24 +++++++++++++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 871eab9cb..7c0988a7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ the public-API contract. - External subtitles on a remote HLS source no longer appear early when the origin's media timestamps do not start at the playlist's time 0. The WebVTT renditions the engine injects (#316) carried no `X-TIMESTAMP-MAP`, so AVPlayer pinned cue time 0 to timestamp 0, and an ffmpeg MPEG-TS transcode cut with `-copyts -max_delay 5000000` showed every cue 10 s early. The engine now reads the first media timestamp of the segment the load opens on, off the load path, and serves the renditions with a matching `X-TIMESTAMP-MAP`; an origin already on its playlist timeline, one whose segment starts before its playlist slot (a transcode restarted at the previous keyframe, which AE#616 corrects), or one it cannot read, keeps the plain WebVTT. - After a seek or a resume into densely typeset ASS (signs re-timed on every frame, several hundred events a second), embedded subtitles stayed missing until the passage ended. The drain decoded at most 256 packets per 0.5 s tick from 15 s behind the playhead, slower than such a passage plays, so it never caught up. A text track's tick now decodes through the playhead plus 12 s whatever the cap (`subtitleDrainUrgentLeadSeconds`, at most `subtitleDrainMaxCatchUpPacketsPerTick` packets); bitmap and teletext tracks keep the steady cap. Text cues from one tick are merged into the retained cues in one pass instead of one insert each, so a large batch after a seek back stays linear. - The #362 harvest-hole hold no longer stops the drain at the pump's edge when the forward prefetch has already read past it. Re-harvested packets take a fresh sequence, so the pump re-reading behind the prefetch broke the harvest order at whatever position it had reached, and that moving seam renewed the hold every tick and kept the drain a few seconds ahead of the playhead instead of its 60 s lead. An order break over a span that the prefetch's current run has read since its first packet, and that the store still holds, is a seam, not a hole. Otherwise the hold works as before. +- `play()` no longer stalls on a forward-only source whose container reports a duration shorter than the stream, as a fragmented MP4 served as one response does (it reports its first fragment). Past that duration the playhead read as parked at the end, so `play()` asked for a rewind to 0, `seek(to:)` rejected it with `sourceNotSeekable`, and `play()` asked again, thousands of times a second, while playback stayed paused. A source that cannot be repositioned (a sequential origin, declared or promoted, or an unseekable byte source) now resumes where it is. A forward-only source genuinely paused at its end no longer rewinds either, because it cannot. - Software video now presents the selected frame when seeking while paused. It decodes through preroll without starting the clock or audio, displays the winning seek's frame, and stays paused instead of retaining the previous picture until Play. - The software video decoder no longer runs more than 16 frame threads. It used one per core, and each frame thread holds back one decoded frame, so a 32-core Mac waited for 31 frames before showing the first one after a load or seek (about 3 s at 10 fps). FFmpeg also warns above 16 threads. Hosts with 16 or fewer cores keep their current thread count. - 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. diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 07fb58ef4..346667825 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -99,6 +99,11 @@ public final class AetherEngine: ObservableObject { } } + /// Whether the loaded VOD source can only be read forwards: a sequential origin, declared or + /// promoted, or a byte source the probe found unseekable. A duration does not change this, and + /// a seek or a rewind on such a source can only be refused. + var sourceIsForwardOnly: Bool { loadedOptions.sequentialOrigin || isSourceSeekable == false } + /// Whether the active session accepts a seek. Live playback requires a DVR window; /// forward-only VOD never advertises arbitrary seeking even when its duration is known. /// Remote HLS uses the native item's measured range instead of container metadata. @@ -109,7 +114,7 @@ public final class AetherEngine: ObservableObject { default: break } if isLive { return seekableLiveRange != nil } - guard !loadedOptions.sequentialOrigin, isSourceSeekable != false, + guard !sourceIsForwardOnly, duration.isFinite, duration > 0 else { return false } if videoRoute == .remoteBypass { guard let nativeHost else { return false } @@ -3567,10 +3572,16 @@ public final class AetherEngine: ObservableObject { /// `AVPlayer.play()` at end-of-media is a no-op, freezing the button. `.ended` is deliberately /// excluded: it is terminal (#63), the host revives it by reloading, and a play press racing the end /// card must not silently restart the finished session. + /// + /// A source that cannot be repositioned never rewinds. Its container can report a duration far + /// short of the stream (a fragmented MP4 served as one response reports its first fragment, a + /// few seconds), which reads as "parked at the end" for the rest of the session. `seek(to:)` + /// rejects the rewind, so `play()` would retry it forever and never resume. nonisolated static func shouldRewindBeforePlay( - state: PlaybackState, currentTime: Double, duration: Double, isLive: Bool + state: PlaybackState, currentTime: Double, duration: Double, isLive: Bool, + sourceCanReposition: Bool = true ) -> Bool { - guard state != .ended else { return false } + guard state != .ended, sourceCanReposition else { return false } return isAtEndOfMedia(currentTime: currentTime, duration: duration, isLive: isLive) } @@ -5491,7 +5502,8 @@ public final class AetherEngine: ObservableObject { // first, then resume. `.ended` is excluded (see shouldRewindBeforePlay): it stays terminal so a // play press racing the host end card does not silently revive a finished session (#63). if Self.shouldRewindBeforePlay( - state: state, currentTime: currentTime, duration: duration, isLive: isLive + state: state, currentTime: currentTime, duration: duration, isLive: isLive, + sourceCanReposition: !sourceIsForwardOnly ) { Task { @MainActor in await self.seek(to: 0) @@ -5720,7 +5732,7 @@ public final class AetherEngine: ObservableObject { return } } - if !isLive, loadedOptions.sequentialOrigin || isSourceSeekable == false { + if !isLive, sourceIsForwardOnly { EngineLog.emit("[AetherEngine] seek(to:\(seconds)) rejected: source is forward-only", category: .engine) // A seek stashed during load must relinquish its optimistic clock as well. let wasDeferred = deferredSeekInFlight diff --git a/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift b/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift index 29352d6da..7e7569ef2 100644 --- a/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift +++ b/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift @@ -1,3 +1,4 @@ +import Combine import Testing @testable import AetherEngine @@ -139,6 +140,29 @@ struct SeekToEndOfMediaTests { #expect(engine.state == .playing) } + @MainActor + @Test("play() on a forward-only source past its container duration resumes without a rewind") + func playOnForwardOnlySourceDoesNotRewind() async throws { + // A fragmented MP4 served as one response reports its first fragment as the duration. The + // rewind play() used to request is a seek this source rejects, and play() then asked again, + // round after round, without ever resuming. + let engine = try AetherEngine() + engine.state = .paused + engine.isSessionReady = true + engine.duration = 3.5 + engine.isSourceSeekable = false + engine.clock.currentTime = 4.6 + var events: [SeekEvent] = [] + let sub = engine.seekEvents.sink { events.append($0) } + defer { sub.cancel(); engine.stop() } + + engine.play() + for _ in 0..<50 { await Task.yield() } + #expect(events.isEmpty) + #expect(engine.state == .playing) + #expect(engine.currentTime == 4.6) + } + @MainActor @Test("play() at .ended stays terminal, no rewind (#63)") func playWhenEndedStaysTerminal() async throws { From 6ae175ac553a9cf6155c379d7bf769f7d36b5d5c Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:34:41 -0400 Subject: [PATCH 4/4] fix(engine): play() asks for one rewind per press, and a software rebuild keeps seekability play() rewound a VOD parked at its end and then called itself again. When seek(to:) rejected the rewind nothing moved, and the same rewind was asked for without end: an errored session parked at its end (noActiveSession) looped exactly as a forward-only source did. The rewind task now resumes the transport once, and drops it when a newer load has started. After an audio switch or reload on the software path isSourceSeekable stayed nil: stopInternal cleared it and only loadNative set it back. canSeek then read false on a seekable source, and a forward-only source lost its forward-only handling. loadSoftware republishes what the host opened, snapshotted in load() like the native session's. Also: shouldRewindBeforePlay takes a required sourceIsForwardOnly, so a new caller cannot fall back to the rewinding default, and the README's end-of-media note says a forward-only source resumes in place. Refs Silo-Server/silo-apple#656 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- README.md | 6 ++-- .../AetherEngine/AetherEngine+Loading.swift | 3 ++ Sources/AetherEngine/AetherEngine.swift | 25 +++++++++----- .../Native/SoftwarePlaybackHost.swift | 4 +++ .../SeekToEndOfMediaTests.swift | 34 ++++++++++++++----- 6 files changed, 56 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c0988a7c..9856fdfa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,8 @@ the public-API contract. - External subtitles on a remote HLS source no longer appear early when the origin's media timestamps do not start at the playlist's time 0. The WebVTT renditions the engine injects (#316) carried no `X-TIMESTAMP-MAP`, so AVPlayer pinned cue time 0 to timestamp 0, and an ffmpeg MPEG-TS transcode cut with `-copyts -max_delay 5000000` showed every cue 10 s early. The engine now reads the first media timestamp of the segment the load opens on, off the load path, and serves the renditions with a matching `X-TIMESTAMP-MAP`; an origin already on its playlist timeline, one whose segment starts before its playlist slot (a transcode restarted at the previous keyframe, which AE#616 corrects), or one it cannot read, keeps the plain WebVTT. - After a seek or a resume into densely typeset ASS (signs re-timed on every frame, several hundred events a second), embedded subtitles stayed missing until the passage ended. The drain decoded at most 256 packets per 0.5 s tick from 15 s behind the playhead, slower than such a passage plays, so it never caught up. A text track's tick now decodes through the playhead plus 12 s whatever the cap (`subtitleDrainUrgentLeadSeconds`, at most `subtitleDrainMaxCatchUpPacketsPerTick` packets); bitmap and teletext tracks keep the steady cap. Text cues from one tick are merged into the retained cues in one pass instead of one insert each, so a large batch after a seek back stays linear. - The #362 harvest-hole hold no longer stops the drain at the pump's edge when the forward prefetch has already read past it. Re-harvested packets take a fresh sequence, so the pump re-reading behind the prefetch broke the harvest order at whatever position it had reached, and that moving seam renewed the hold every tick and kept the drain a few seconds ahead of the playhead instead of its 60 s lead. An order break over a span that the prefetch's current run has read since its first packet, and that the store still holds, is a seam, not a hole. Otherwise the hold works as before. -- `play()` no longer stalls on a forward-only source whose container reports a duration shorter than the stream, as a fragmented MP4 served as one response does (it reports its first fragment). Past that duration the playhead read as parked at the end, so `play()` asked for a rewind to 0, `seek(to:)` rejected it with `sourceNotSeekable`, and `play()` asked again, thousands of times a second, while playback stayed paused. A source that cannot be repositioned (a sequential origin, declared or promoted, or an unseekable byte source) now resumes where it is. A forward-only source genuinely paused at its end no longer rewinds either, because it cannot. +- `play()` no longer loops when the rewind it asks for is rejected. A VOD parked at its end rewinds to 0 before playing (#164), and `play()` then called itself again; when `seek(to:)` rejected the rewind, nothing moved and the same rewind was asked for thousands of times a second while playback stayed paused. It hit a forward-only source whose container reports a duration shorter than the stream, as a fragmented MP4 served as one response does (it reports its first fragment), and an errored session parked at its end. A forward-only source (a sequential origin, declared or promoted, or an unseekable byte source) now resumes where it is without a rewind, including one genuinely paused at its end, which cannot rewind; any other `play()` asks for one rewind per press and then resumes. +- After an audio switch or a reload on the software path, `isSourceSeekable` is published again. The rebuild cleared it and only the native path set it back, so `canSeek` read false on a seekable source and a forward-only source lost its forward-only handling until the next load. - Software video now presents the selected frame when seeking while paused. It decodes through preroll without starting the clock or audio, displays the winning seek's frame, and stays paused instead of retaining the previous picture until Play. - The software video decoder no longer runs more than 16 frame threads. It used one per core, and each frame thread holds back one decoded frame, so a 32-core Mac waited for 31 frames before showing the first one after a load or seek (about 3 s at 10 fps). FFmpeg also warns above 16 threads. Hosts with 16 or fewer cores keep their current thread count. - 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. diff --git a/README.md b/README.md index 148284e12..6e3ded2c5 100644 --- a/README.md +++ b/README.md @@ -208,8 +208,10 @@ player.$state // .idle, .loading, .playing, .paused, .seeking, .ended, // .ended = played to completion (any backend); .idle = pre-load / stopped. // .ended is TERMINAL: seek is rejected and play() does not revive it, so a // replay is another load(). A VOD merely parked at its final frame is the - // other case and play() rewinds it. How every other failure arrives, and - // why a CancellationError out of load() is not one, is in docs/api.md. + // other case and play() rewinds it, once per press; a forward-only source + // (canSeek false) cannot rewind and resumes where it is. How every other + // failure arrives, and why a CancellationError out of load() is not one, is + // in docs/api.md. player.$errorInfo // PlaybackErrorInfo?, the machine-readable half of .error: a stable // PlaybackErrorKind plus the underlying NSError domain and code. Classify on // this. The message cannot: on the native paths it is AVPlayerItem.error diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index 74209434f..4fe57864a 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -2136,6 +2136,9 @@ extension AetherEngine { host.stop() try checkLoadCurrent(generation) } + // A rebuild (audio switch, reload) ran stopInternal, which cleared the capability; without it + // `canSeek` reads false on a seekable source and the forward-only rules stop applying. + isSourceSeekable = host.openedSourceIsSeekable } /// Open `AudioPlaybackHost` for an audio-only source. No HLS pipeline, display layer, or display-criteria handshake. Same lifecycle as `loadSoftware`. diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 346667825..f534ce813 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -3573,15 +3573,15 @@ public final class AetherEngine: ObservableObject { /// excluded: it is terminal (#63), the host revives it by reloading, and a play press racing the end /// card must not silently restart the finished session. /// - /// A source that cannot be repositioned never rewinds. Its container can report a duration far - /// short of the stream (a fragmented MP4 served as one response reports its first fragment, a - /// few seconds), which reads as "parked at the end" for the rest of the session. `seek(to:)` - /// rejects the rewind, so `play()` would retry it forever and never resume. + /// A forward-only source never rewinds. Its container can report a duration far short of the + /// stream (a fragmented MP4 served as one response reports its first fragment, a few seconds), + /// which reads as "parked at the end" for the rest of the session, and `seek(to:)` rejects the + /// rewind anyway. nonisolated static func shouldRewindBeforePlay( state: PlaybackState, currentTime: Double, duration: Double, isLive: Bool, - sourceCanReposition: Bool = true + sourceIsForwardOnly: Bool ) -> Bool { - guard state != .ended, sourceCanReposition else { return false } + guard state != .ended, !sourceIsForwardOnly else { return false } return isAtEndOfMedia(currentTime: currentTime, duration: duration, isLive: isLive) } @@ -5503,14 +5503,23 @@ public final class AetherEngine: ObservableObject { // play press racing the host end card does not silently revive a finished session (#63). if Self.shouldRewindBeforePlay( state: state, currentTime: currentTime, duration: duration, isLive: isLive, - sourceCanReposition: !sourceIsForwardOnly + sourceIsForwardOnly: sourceIsForwardOnly ) { + let generation = loadGeneration Task { @MainActor in await self.seek(to: 0) - self.play() + // One rewind per press. A rejected or superseded seek leaves the playhead where it was, + // and calling play() again would ask for the same rewind without end. A load that + // started meanwhile owns the transport now. + guard self.loadGeneration == generation else { return } + self.resumeTransport() } return } + resumeTransport() + } + + private func resumeTransport() { activeTransportHost?.play() if state == .paused || state == .loading { state = .playing diff --git a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift index d34bb3f50..33496d87a 100644 --- a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift +++ b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift @@ -135,6 +135,9 @@ final class SoftwarePlaybackHost { private var audioDecoder: AudioDecoder? private var audioOutput: AudioOutput? private var demuxer: Demuxer? + /// Whether the source this host opened can be repositioned, taken once in `load()` so the main + /// actor never polls the demuxer the read loop owns. The engine republishes it after a rebuild. + private(set) var openedSourceIsSeekable = false private var vodPacketReadAhead: SoftwarePacketReadAhead? /// #687: this host's entries in the process-wide retention ledger, released on `stop()`. private var retentionClaims: [RetentionClaims.Claim] = [] @@ -890,6 +893,7 @@ final class SoftwarePlaybackHost { forwardBufferSegments: Int? = nil ) async throws { self.demuxer = dem + openedSourceIsSeekable = dem.isSourceSeekable self.duration = dem.duration self.isLive = isLive diff --git a/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift b/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift index 7e7569ef2..fa4b43d3f 100644 --- a/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift +++ b/Tests/AetherEngineTests/SeekToEndOfMediaTests.swift @@ -67,19 +67,19 @@ struct SeekToEndOfMediaTests { @Test("Rewind before play when parked at end-of-media") func rewindWhenParkedAtEnd() { - #expect(AetherEngine.shouldRewindBeforePlay(state: .paused, currentTime: 100, duration: 100, isLive: false)) - #expect(AetherEngine.shouldRewindBeforePlay(state: .playing, currentTime: 100, duration: 100, isLive: false)) + #expect(AetherEngine.shouldRewindBeforePlay(state: .paused, currentTime: 100, duration: 100, isLive: false, sourceIsForwardOnly: false)) + #expect(AetherEngine.shouldRewindBeforePlay(state: .playing, currentTime: 100, duration: 100, isLive: false, sourceIsForwardOnly: false)) } @Test(".ended never rewinds (terminal, #63 end-card must not be revived)") func endedNeverRewinds() { - #expect(!AetherEngine.shouldRewindBeforePlay(state: .ended, currentTime: 100, duration: 100, isLive: false)) + #expect(!AetherEngine.shouldRewindBeforePlay(state: .ended, currentTime: 100, duration: 100, isLive: false, sourceIsForwardOnly: false)) } @Test("No rewind mid-stream or when live") func noRewindMidStreamOrLive() { - #expect(!AetherEngine.shouldRewindBeforePlay(state: .paused, currentTime: 50, duration: 100, isLive: false)) - #expect(!AetherEngine.shouldRewindBeforePlay(state: .paused, currentTime: 100, duration: 100, isLive: true)) + #expect(!AetherEngine.shouldRewindBeforePlay(state: .paused, currentTime: 50, duration: 100, isLive: false, sourceIsForwardOnly: false)) + #expect(!AetherEngine.shouldRewindBeforePlay(state: .paused, currentTime: 100, duration: 100, isLive: true, sourceIsForwardOnly: false)) } @Test("A source that cannot be repositioned never rewinds, whatever its duration claims") @@ -88,12 +88,12 @@ struct SeekToEndOfMediaTests { // 3.6 s for a film with over an hour left. Twenty minutes in, that reads as parked at // the end, and the rewind is a seek this source cannot serve. #expect(!AetherEngine.shouldRewindBeforePlay( - state: .paused, currentTime: 1_196.7, duration: 3.6, isLive: false, sourceCanReposition: false)) + state: .paused, currentTime: 1_196.7, duration: 3.6, isLive: false, sourceIsForwardOnly: true)) #expect(!AetherEngine.shouldRewindBeforePlay( - state: .paused, currentTime: 100, duration: 100, isLive: false, sourceCanReposition: false)) + state: .paused, currentTime: 100, duration: 100, isLive: false, sourceIsForwardOnly: true)) // The same playhead on a source that can rewind still does. #expect(AetherEngine.shouldRewindBeforePlay( - state: .paused, currentTime: 100, duration: 100, isLive: false, sourceCanReposition: true)) + state: .paused, currentTime: 100, duration: 100, isLive: false, sourceIsForwardOnly: false)) } // MARK: - Integration: real seek() / play() paths (no hosts) @@ -163,6 +163,24 @@ struct SeekToEndOfMediaTests { #expect(engine.currentTime == 4.6) } + @MainActor + @Test("play() asks for one rewind per press, even when the seek is rejected") + func playRewindsOncePerPress() async throws { + // An errored session keeps its playhead; seek(to:) rejects the rewind with noActiveSession and + // nothing moves. play() used to ask again on every round, thousands of times a second. + let engine = try AetherEngine() + engine.state = .error("decoder failed") + engine.duration = 100 + engine.clock.currentTime = 100 + var events: [SeekEvent] = [] + let sub = engine.seekEvents.sink { events.append($0) } + defer { sub.cancel(); engine.stop() } + + engine.play() + for _ in 0..<50 { await Task.yield() } + #expect(events.map(\.outcome) == [.rejected(.noActiveSession)]) + } + @MainActor @Test("play() at .ended stays terminal, no rewind (#63)") func playWhenEndedStaysTerminal() async throws {