Skip to content

Commit dd869f9

Browse files
author
Vincent Herbst
committed
Merge branch 'fix/ae446-rejoin-is-not-a-host-scrub'
2 parents 31f3a5a + 5360e1c commit dd869f9

11 files changed

Lines changed: 541 additions & 38 deletions

Sources/AetherEngine/AetherEngine+Live.swift

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,97 @@ extension AetherEngine {
148148
?? (outputFloor + playlistShiftSeconds)
149149
}
150150

151+
/// AE#446 round 4: measure how far the current item's own timeline sits below the session's.
152+
///
153+
/// A live item's zero is the first segment ITS playlist listed. The producer's window floor and
154+
/// the item's own floor slide together, because one rule sizes both (`LiveWindowSizing` is the
155+
/// single source of truth for the playlist's first visible segment and the cache's eviction), so
156+
/// their difference is the offset and it holds still while both ends move. Measured on the harness
157+
/// across twelve seconds of sliding: 50.00 s at every sample, while the item's floor walked from
158+
/// 0.00 to 15.00 and the producer's from 51.40 to 66.40.
159+
///
160+
/// Latched per item, because it is a property of the playlist that item loaded, and re-measured
161+
/// when the item under the host changes. It reads 0 for the item a session starts with, which is
162+
/// why nothing needed it until a swap attached a second one.
163+
@MainActor
164+
func measureLiveItemAxisOffset() {
165+
guard isLive, let host = nativeHost else { return }
166+
guard host.itemGeneration != liveItemAxisOffsetGeneration else { return }
167+
// A range of zero width is an item that has not reported yet, not an item at the origin.
168+
guard host.seekableEnd > host.seekableStart,
169+
let producerFloor = residentLiveFloorSessionSeconds() else { return }
170+
let offset = Self.liveItemAxisOffset(producerFloorSession: producerFloor,
171+
itemSeekableStart: host.seekableStart,
172+
shift: playlistShiftSeconds)
173+
guard offset.isFinite else { return }
174+
liveItemAxisOffsetGeneration = host.itemGeneration
175+
liveItemAxisOffsetSeconds = offset
176+
guard liveItemAxisOffsetSeconds > 0.01 else { return }
177+
EngineLog.emit(
178+
"[AetherEngine] #446 this item's playlist began \(String(format: "%.2f", liveItemAxisOffsetSeconds))s "
179+
+ "into the session, so its own clock reads that much below the session's; folding it into "
180+
+ "every conversion for as long as this item is the one playing",
181+
category: .engine)
182+
}
183+
184+
/// AE#446 round 4: the arithmetic behind `measureLiveItemAxisOffset`, on its own so the case can
185+
/// be stated without a session.
186+
///
187+
/// The producer's floor is on the session axis; the item's floor is on the item's own. One rule
188+
/// sizes both, so their difference is what separates the axes, and it is a difference rather than
189+
/// an assumption. Negative is not a case that can be acted on (an item claiming to hold content
190+
/// older than the producer does), and it folds to 0, which is the pre-swap behaviour.
191+
nonisolated static func liveItemAxisOffset(
192+
producerFloorSession: Double, itemSeekableStart: Double, shift: Double
193+
) -> Double {
194+
Swift.max(0, producerFloorSession - (itemSeekableStart + shift))
195+
}
196+
197+
/// AE#446 round 4: the three readings a rejoin's placement is argued from, on one line.
198+
///
199+
/// They were only ever available separately, which is why an item's clock and an item's seekable
200+
/// range could disagree for a whole investigation without anyone being able to say so. Bounded to
201+
/// the seconds after a swap, so a live session does not pay for it.
202+
@MainActor
203+
func auditLiveRejoinPlacement() {
204+
guard let until = liveRejoinAuditUntil, let host = nativeHost else { return }
205+
let now = Date()
206+
guard now < until else { liveRejoinAuditUntil = nil; return }
207+
if let last = liveRejoinAuditLastEmit, now.timeIntervalSince(last) < 1.0 { return }
208+
liveRejoinAuditLastEmit = now
209+
let producer = residentLiveRangeSessionSeconds()
210+
EngineLog.emit(
211+
"[AetherEngine] #446 placement audit: item clock \(String(format: "%.2f", nativeClockSeconds))s "
212+
+ "in item range \(String(format: "%.2f", host.seekableStart))..\(String(format: "%.2f", host.seekableEnd))s, "
213+
+ "shift \(String(format: "%.2f", playlistShiftSeconds))s + item offset "
214+
+ "\(String(format: "%.2f", liveItemAxisOffsetSeconds))s -> session \(String(format: "%.2f", currentTime))s; "
215+
+ "the producer holds "
216+
+ (producer.map { "\(String(format: "%.2f", $0.lowerBound))..\(String(format: "%.2f", $0.upperBound))s" } ?? "nothing it can state"),
217+
category: .engine)
218+
}
219+
220+
/// AE#446 round 4: what the producer holds right now, on the session axis, both ends.
221+
///
222+
/// This is the range a rejoin is measured against, and it is deliberately not either of the two
223+
/// the engine publishes. `LiveWindow.edgeTime` is a running maximum an outage freezes BELOW the
224+
/// playhead that legitimately ran past it, and a freshly swapped item's `seekableEnd` is a range
225+
/// it has not finished reporting at the readiness instant the rejoin replays in (measured on the
226+
/// harness: 43.4 s while the place held was 71.4 s and the producer was cutting past 100 s). The
227+
/// cache is the only party that is neither ahead of nor behind itself.
228+
///
229+
/// nil where there is no cache to ask, which leaves the rejoin exactly where it was.
230+
func residentLiveRangeSessionSeconds() -> ClosedRange<Double>? {
231+
guard let session = nativeVideoSession,
232+
let floorOutput = session.residentFloorOutputSeconds(),
233+
let ceilingOutput = session.residentCeilingOutputSeconds() else { return nil }
234+
let floor = presentationAxis.sourceSeconds(forItemSeconds: floorOutput)
235+
?? (floorOutput + playlistShiftSeconds)
236+
let ceiling = presentationAxis.sourceSeconds(forItemSeconds: ceilingOutput)
237+
?? (ceilingOutput + playlistShiftSeconds)
238+
guard ceiling >= floor else { return nil }
239+
return floor...ceiling
240+
}
241+
151242
/// AE#442: the TARGETDURATION the live playlist is serving, nil on every path that serves none
152243
/// (remote HLS live, the software live path, and before the first playlist build).
153244
var liveTargetDurationSeconds: Double? {
@@ -161,6 +252,7 @@ extension AetherEngine {
161252
w.noteEdge(edgeSessionTime)
162253
w.notePlayhead(currentTime)
163254
w.noteResidentFloor(residentLiveFloorSessionSeconds())
255+
auditLiveRejoinPlacement()
164256
liveWindow = w
165257
// AE#442: tick-to-tick advancement, not a running maximum: a backward DVR seek drops the
166258
// playhead, and the next advancing publish has to be able to record the new, larger distance.
@@ -174,6 +266,32 @@ extension AetherEngine {
174266
clock.behindLiveSeconds = w.behindLiveSeconds
175267
}
176268

269+
/// AE#446 round 4: who asked for a seek. The two differ in exactly two places, both about a live
270+
/// session that advertises no DVR window: whether the seek is refused outright, and whether its
271+
/// landing is measured against what the session offers or against what the item holds.
272+
///
273+
/// A host that draws no scrubber can still have a place to come back to. Reported from a device:
274+
/// the outage swap carried the held position, the replay went out through the public `seek(to:)`
275+
/// like any host scrub, and the live-only guard refused it before it could land.
276+
enum SeekOrigin: Sendable {
277+
/// A scrub the host asked for, bound by the contract `seekableLiveRange` states.
278+
case host
279+
/// The engine coming back to a position it decided itself (the AE#446 outage swap, AE#442's
280+
/// in-place recovery reload). Not a scrub, and not bound by the scrubber's contract.
281+
case liveRejoin
282+
}
283+
284+
/// AE#446 round 4: whether a live seek is refused for having no DVR window to land in.
285+
///
286+
/// The refusal is the host contract's defence-in-depth: hosts hide the scrubber when
287+
/// `seekableLiveRange` is nil, and one that does not must not put the item somewhere it cannot
288+
/// play from. It says nothing about the engine's own rejoin, which picked its position out of
289+
/// content the session itself served.
290+
nonisolated static func liveSeekRefusedWithoutDVR(origin: SeekOrigin, windowSeconds: Double?) -> Bool {
291+
guard origin == .host else { return false }
292+
return windowSeconds == nil
293+
}
294+
177295
/// AE#446 round 3: where a live seek lands, decided from ONE sample of the item's own clock.
178296
///
179297
/// The two halves used to read different clocks. The target was clamped against
@@ -199,14 +317,31 @@ extension AetherEngine {
199317
window: LiveWindow,
200318
itemEnd: Double,
201319
shift: Double,
202-
axis: PresentationAxisMap
320+
axis: PresentationAxisMap,
321+
origin: SeekOrigin = .host,
322+
residentRange: ClosedRange<Double>? = nil,
323+
itemAxisOffset: Double = 0
203324
) -> (sessionTarget: Double, clockTarget: Double) {
204325
// An item with no seekable range of its own yet has nothing to sample; the window's own edge
205326
// is then the only edge there is, and clamping against `shift` alone would collapse the range.
206-
let edge = itemEnd > 0 ? itemEnd + shift : window.edgeTime
207-
let sessionTarget = window.clamp(requested, edge: edge)
327+
let edge = itemEnd > 0 ? itemEnd + shift + itemAxisOffset : window.edgeTime
328+
// AE#446 round 4: a host scrub is bound by what the session ADVERTISES, and the engine's own
329+
// rejoin by what the producer HOLDS. They are different questions, and at the moment a rejoin
330+
// runs they have different answers: the advertised range is measured against an edge that is
331+
// stale in one direction or the other (see `residentLiveRangeSessionSeconds`), while the
332+
// carried position is content this same session cut and served, so the only thing that can
333+
// disqualify it is eviction.
334+
let sessionTarget: Double
335+
if origin == .liveRejoin, let resident = residentRange {
336+
sessionTarget = Swift.min(Swift.max(requested, resident.lowerBound), resident.upperBound)
337+
} else {
338+
sessionTarget = window.clamp(requested, edge: edge)
339+
}
340+
// AE#446 round 4: and then down onto the item's own axis, which for an item attached after
341+
// the window slid begins above the session's zero. See `measureLiveItemAxisOffset`.
208342
let clockTarget = Swift.max(
209-
0, axis.itemSeconds(forSourceSeconds: sessionTarget) ?? (sessionTarget - shift))
343+
0, (axis.itemSeconds(forSourceSeconds: sessionTarget) ?? (sessionTarget - shift))
344+
- itemAxisOffset)
210345
return (sessionTarget, clockTarget)
211346
}
212347

Sources/AetherEngine/AetherEngine+Loading.swift

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ extension AetherEngine {
2020
func applyNativeHostClockTick(_ value: Double) {
2121
// nativeClockSeconds preserves the raw AVPlayer clock for onPlaylistShiftChanged to re-derive against.
2222
nativeClockSeconds = value
23+
// AE#446 round 4: before anything folds, establish which axis this item's clock is even on.
24+
measureLiveItemAxisOffset()
2325
// Newest seam at or before the raw clock wins: activates seams on forward play, re-applies pre-seam shift on backward DVR seeks.
2426
if let active = presentationAxis.shiftSeconds(atItemSeconds: value) {
2527
playlistShiftSeconds = active
@@ -28,12 +30,13 @@ extension AetherEngine {
2830
// AE#105: fold the disc's clip-0 STC base back out so the published playhead sits on the same
2931
// 0-based axis as the MPLS duration (origin 0 for normal/live -> no-op).
3032
clock.currentTime = PresentationAxis.display(
31-
sourcePTS: value + playlistShiftSeconds,
33+
sourcePTS: value + playlistShiftSeconds + liveItemAxisOffsetSeconds,
3234
origin: displayOrigin(forShift: playlistShiftSeconds))
3335
}
3436
// Live edge must fold with the same playlistShiftSeconds as the playhead; opposite sign would make behindLiveSeconds meaningless.
3537
if isLive {
36-
publishLiveWindow(edgeSessionTime: (nativeHost?.seekableEnd ?? 0) + playlistShiftSeconds)
38+
publishLiveWindow(edgeSessionTime: (nativeHost?.seekableEnd ?? 0) + playlistShiftSeconds
39+
+ liveItemAxisOffsetSeconds)
3740
}
3841
}
3942

@@ -221,10 +224,10 @@ extension AetherEngine {
221224
// #127: replay the latest host seek that arrived while the item was pre-ready.
222225
// #178: not while still .loading (autostart paths hold .loading past readiness);
223226
// replaying now would just re-stash. The state didSet resolves that case.
224-
if ready, self.state != .loading, let pending = self.pendingPreReadySeekSeconds {
225-
self.pendingPreReadySeekSeconds = nil
226-
EngineLog.emit("[AetherEngine] replaying deferred pre-ready seek to \(String(format: "%.2f", pending))s (#127)", category: .engine)
227-
Task { @MainActor in await self.seek(to: pending) }
227+
if ready, self.state != .loading, let pending = self.pendingPreReadySeek {
228+
self.pendingPreReadySeek = nil
229+
EngineLog.emit("[AetherEngine] replaying deferred pre-ready seek to \(String(format: "%.2f", pending.seconds))s (#127)", category: .engine)
230+
Task { @MainActor in await self.seek(to: pending.seconds, origin: pending.origin) }
228231
}
229232
}
230233
.store(in: &cancellables)
@@ -1413,7 +1416,7 @@ extension AetherEngine {
14131416
// way (HLSVideoEngine drops the resume anchor for a sequential origin), so leaving the
14141417
// item on the EVENT edge default would start it mid-archive with no way back.
14151418
if !isLive, loadedOptions.sequentialOrigin {
1416-
pendingPreReadySeekSeconds = 0.0
1419+
pendingPreReadySeek = PendingPreReadySeek(seconds: 0.0, origin: .host)
14171420
}
14181421
// AE#158: consume-and-reset so only the load() that armed the handover swaps in place; audio-switch
14191422
// and recovery reloads keep their own contracts.

0 commit comments

Comments
 (0)