Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ the public-API contract.

### Fixed

- **A live stream read off its URL and refused for a while keeps its session.** The continuous
MPEG-TS form of an IPTV channel is one connection for as long as it plays, and a connection-limited
panel answers its reconnects with 509 while another device on the account holds the last slot, for
seconds to tens of seconds. The reader climbed its 0.5, 1, 2, 4, 8 s ladder, gave up after six
refusals, and each refusal armed the origin's request pacer (a quiet of up to 15 s, with each
request waiting up to 10 s on it), which the reload after it then waited out. A live stream's
429/503/509 is now asked again every second, or after the origin's Retry-After up to 5 s, for up to
60 s, the way the live HLS ingest asks for its playlist (#778); its refusals arm no pacer and lower
no budget, and the books still say the origin refused, so the revive arm tells metered from lost. A
live open the origin answers with a status fails typed with that status at once
(`AVIOReaderError.httpStatus`, `sourceRateLimited` or `sourceRefused` at the host), where the read
loop used to spend the open budget on the same request and hand back an untyped read failure.
`LiveStreamRefusalRetryTests`.
- **Profile 7 FEL chroma residual is sampled where the base chroma sits.** With a quarter-resolution
enhancement layer (1080p under 4K), the chroma kernel read the enhancement chroma at texel centres,
a quarter of an EL chroma texel up and to the left of the co-sited (top-left) chroma location HDR
Expand Down
82 changes: 77 additions & 5 deletions Sources/AetherEngine/Demuxer/AVIOReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,18 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
private func noteOriginRefusal(status: Int, retryAfter: TimeInterval? = nil,
respondedBy: URL? = nil) {
let refusing = respondedBy ?? requestURL()
if isLive {
// A live stream's refusal is retried on its own short cadence (`liveRefusalRetryInterval`),
// which is the pacing: arming the pacer as well would hold the retry, and the reload or
// reopen after it, for the quiet period (up to 15 s, and up to 10 s of waiting on each
// request) over a refusal that clears in seconds. The books still carry the refusal, so
// the engine's revive arm can tell a metered source from a lost one.
OriginRequestBudget.shared.noteRefusalWitnessed(for: refusing)
if OriginRequestBudget.originKey(for: refusing) != OriginRequestBudget.originKey(for: url) {
OriginRequestBudget.shared.noteRefusalWitnessed(for: url)
}
return
}
OriginRequestBudget.shared.noteRefusal(for: refusing, status: status, retryAfter: retryAfter)
// The refusal usually comes back from the post-redirect CDN, while the engine's revive arm
// only knows the URL the host loaded. Where those differ (a proxy that 302s to a signed CDN
Expand Down Expand Up @@ -749,6 +761,26 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
// Distinct axis from unproductiveReconnects: NOT reset by seekReconnect, so parse-driven
// seeks cannot mask a throttled origin into an infinite reconnect loop (AetherEngine#71).
private static let rateLimitMaxStreak = 6
/// A LIVE stream refused mid-session is asked for again on a steady, short cadence rather than
/// up the exponential ladder, and for a bounded while rather than a bounded count. The refusal
/// this answers is a connection-limited IPTV panel's 509 while another device on the account
/// holds its last slot: it lasts seconds to tens of seconds and clears the moment that slot is
/// free, and every second of waiting past that is a second the picture stays frozen with the
/// origin ready to serve. One small request a second is what the panel's own clients do, and
/// what the live HLS ingest does for the same refusal (`HLSLiveIngestReader`). The origin's own
/// Retry-After stretches the wait, up to `liveRefusalMaximumRetryWait`.
static let liveRefusalRetryInterval: TimeInterval = 1
static let liveRefusalMaximumRetryWait: TimeInterval = 5
/// How long a live stream keeps asking before the refusal is the session's answer.
static let liveRefusalRetryBudget: TimeInterval = 60
/// Test seam: a shorter budget, so the give-up is exercised without a minute of wall clock.
nonisolated(unsafe) static var liveRefusalRetryBudgetForTesting: TimeInterval?

/// The wait before a live stream's refused request is made again: a second, or the origin's
/// Retry-After (0 for none) when that is longer, never more than `liveRefusalMaximumRetryWait`.
static func liveRefusalWait(retryAfter: TimeInterval) -> TimeInterval {
min(max(liveRefusalRetryInterval, retryAfter), liveRefusalMaximumRetryWait)
}
// Rate-limited attempts that keep the pinned redirect target before one attempt through the
// source URL is spent on a fresh redirect. Three paced attempts (~7 s of ladder) ride out the
// lingering-slot 509 of a connection-capped panel (#307 follow-up: the slot frees in seconds);
Expand Down Expand Up @@ -973,6 +1005,8 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
// streak, resets on real read progress and whenever the header store's revision moves.
private var credentialRefusalStreak = 0
private var credentialStreakRevision: UInt64?
/// When the current rate-limited streak began, for the live budget. Cleared with the streak.
private var rateLimitStreakStartedAt: DispatchTime?

/// Detour LRU block cache (its own leaf lock, never held across `fetchChunk`/network or
/// `winCond`). Stores only full-size blocks; short bodies are served once but never cached
Expand Down Expand Up @@ -1283,6 +1317,18 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
winCond.unlock()
startPersistentConnection(at: 0, boundedTo: boundedInitialFetch)
var opened = awaitFirstPersistentData()
// A live open the origin answered with a status is reported as that answer, the
// way the live HLS ingest reports a refused join: the host retries a 509 on its own
// cadence with the right words for it, where the read loop's ladder would have spent
// the open budget on the same request and handed back an untyped read failure.
if !opened, isLive, let refusal = refusalOfLiveOpenConnection() {
EngineLog.emit(
"[AVIOReader] \(label) live open refused status=\(refusal); failing the open typed",
category: .demux)
markClosed()
close()
throw AVIOReaderError.httpStatus(refusal)
}
// AE#693: one refusal is the origin's answer to that request, not yet to the
// resource. The #378 path below takes a 401/403/404/410 here as final and settles
// the session forward-only, which costs it every seek and the resume position. An
Expand Down Expand Up @@ -1548,6 +1594,15 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
return (connEnded && Self.isResolvedExpiryStatus(connStatus)) ? connStatus : nil
}

/// The status a live open's first connection ended with before any data, when it was an answer
/// (any 4xx or 5xx, the rate-limit statuses included); nil for a connection still open, one that
/// delivered, or one that ended with no status (a transport failure, which the read loop rides).
private func refusalOfLiveOpenConnection() -> Int? {
winCond.lock()
defer { winCond.unlock() }
return (connEnded && window.isEmpty && connStatus >= 400) ? connStatus : nil
}

/// The streaming GET was answered with a status instead of a body, or the ranged open was
/// already refused with one and the unranged GET then delivered nothing either. Either way the
/// demuxer would be handed an empty stream (or an error page) and report it as invalid data;
Expand Down Expand Up @@ -2379,6 +2434,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
if fetched {
unproductiveReconnects = 0
rateLimitStreak = 0
rateLimitStreakStartedAt = nil
emitNetworkPhase(.flowing) // the fetch crossed the network: the origin is serving (#85/#410)
}
detourTrackSequential(at: curPosition, length: n)
Expand Down Expand Up @@ -2443,6 +2499,7 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
unproductiveReconnects = 0 // real progress
rateLimitStreak = 0 // real progress clears the 429 give-up streak (#71)
credentialRefusalStreak = 0 // and the credential one (#792)
rateLimitStreakStartedAt = nil
emitNetworkPhase(.flowing) // recovered: source delivering again (#85/#410)
}
// No flow installed and the consumer has drawn down to low water: request at the
Expand Down Expand Up @@ -2610,7 +2667,8 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
// nothing, so the exponential pacing charged against the target that did refuse is not
// its debt. A server-sent Retry-After still applies: that is the origin's own ask, and
// the source belongs to the same origin.
backoffBeforeReconnect(streak: repinned ? 0 : backoffStreak, retryAfter: retryAfter)
backoffBeforeReconnect(streak: repinned ? 0 : backoffStreak, retryAfter: retryAfter,
isRateLimited: isRateLimited)
diag.recordBackoff(ms: msSince(backoffStart))
timedReconnect(seek: false, at: frontier)
}
Expand Down Expand Up @@ -2725,7 +2783,10 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
/// Exponential backoff (0.5s..8s) growing with streak; immediate on streak=0. How long the
/// ladder waits before its next attempt. Shared by the blocking backoff below and the
/// non-blocking one in `chargeFaultedRunwayRefill`, so both pace an origin identically.
private func backoffDelay(streak: Int, retryAfter: TimeInterval) -> TimeInterval {
private func backoffDelay(streak: Int, retryAfter: TimeInterval, isRateLimited: Bool = false) -> TimeInterval {
if isLive, isRateLimited {
return Self.liveRefusalWait(retryAfter: retryAfter) * backoffScale
}
let expo = streak <= 0 ? 0.0 : min(Double(1 << min(streak, 4)) * 0.5, 8.0)
return min(max(expo, retryAfter), 15.0) * backoffScale
}
Expand Down Expand Up @@ -2771,7 +2832,8 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
: isCredentialRefusal ? credentialRefusalStreak : unproductiveReconnects
let repinned = isCredentialRefusal ? false : dropPinIfTheRefusalCallsForIt(isRateLimited: isRateLimited)
lastUnplannedReconnectAt = Date()
let delay = backoffDelay(streak: repinned ? 0 : streak, retryAfter: retryAfter)
let delay = backoffDelay(streak: repinned ? 0 : streak, retryAfter: retryAfter,
isRateLimited: isRateLimited)
winCond.lock()
nextFaultedRefillAt = Date().addingTimeInterval(delay)
winCond.unlock()
Expand All @@ -2784,8 +2846,8 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
}

/// Blocking form of the wait above: sleeps in 0.1s slices so a close is honoured promptly.
private func backoffBeforeReconnect(streak: Int, retryAfter: TimeInterval) {
let total = backoffDelay(streak: streak, retryAfter: retryAfter)
private func backoffBeforeReconnect(streak: Int, retryAfter: TimeInterval, isRateLimited: Bool = false) {
let total = backoffDelay(streak: streak, retryAfter: retryAfter, isRateLimited: isRateLimited)
if total <= 0 { return }
var slept = 0.0
while slept < total {
Expand All @@ -2802,6 +2864,16 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
/// Internal (not private) so the bounded give-up is unit-tested without a live origin.
func recordRateLimitAndShouldGiveUp() -> Bool {
rateLimitStreak += 1
if isLive {
// A live stream is given a while, not a count: at one attempt a second the count would
// be spent in six seconds, inside the refusal this cadence exists to ride out.
let now = DispatchTime.now()
let started = rateLimitStreakStartedAt ?? now
rateLimitStreakStartedAt = started
let budget = Self.liveRefusalRetryBudgetForTesting ?? Self.liveRefusalRetryBudget
let elapsed = Double(now.uptimeNanoseconds &- started.uptimeNanoseconds) / 1_000_000_000
return elapsed > budget
}
return rateLimitStreak > Self.rateLimitMaxStreak
}

Expand Down
Loading
Loading