Skip to content

fix(live): a live stream read off its URL rides out an IPTV panel's refusals - #796

Merged
superuser404notfound merged 3 commits into
superuser404notfound:mainfrom
jpsts:fix/live-url-refusal-retries
Oct 16, 2026
Merged

superuser404notfound merged 3 commits into
superuser404notfound:mainfrom
jpsts:fix/live-url-refusal-retries

Conversation

@jpsts

@jpsts jpsts commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A live stream read straight off its URL (the continuous MPEG-TS form an IPTV panel serves for a channel) is one connection for as long as it plays. A connection-limited panel answers that connection's reconnects with 509 while another device on the account holds the last slot, for seconds to tens of seconds, and serves again the moment the slot is free. AVIOReader handles that refusal the way it handles a metered VOD origin, which costs a live viewer a frozen picture and then a long black: this gives a live stream the same steady retry the live HLS ingest got in #778, and types a refused live open.

What changed

The reader's refusal handling on a live stream, before this change:

  1. The ladder. A 429/503/509 on a reconnect is retried after 0.5, 1, 2, 4 and 8 s (backoffDelay), and recordRateLimitAndShouldGiveUp gives up after six consecutive refusals: about 16 s, inside the refusal this is about, and the read fails with EIO.
  2. The pacer. Every refusal reaches OriginRequestBudget.noteRefusal, which arms the origin's request pacer (a quiet of 2, 4, 8, then 15 s, then one request every 2 s until 60 s without a refusal) and halves the learned concurrency for the rest of the process. The host's reload, or the producer's reopen, then waits up to 10 s for its first request (proceeded without waiting the pacer out after 10004ms) against an origin that would serve it.
  3. The open. A refused first connection on a live open is not thrown typed: awaitFirstPersistentData returns with the connection ended, the live path has no refusal branch (AE#693's is !isLive), and the open "proceeds to read-loop reconnect", so the demuxer's open spends the ladder on the same request and fails with an untyped read error the host cannot name.

With this change, on a live stream only:

  • A rate-limited reconnect is asked again every 1 s, or after the origin's Retry-After up to 5 s (liveRefusalRetryInterval, liveRefusalMaximumRetryWait), for up to 60 s from the first refusal of the streak (liveRefusalRetryBudget), then the read fails with EIO as before. The budget is a while rather than a count because at one attempt a second a count of six is spent in six seconds.
  • Those refusals no longer arm the pacer or lower the budget. The books still carry the refusal through noteRefusalWitnessed, so HLSVideoEngine+LiveReopen's refusedRecently still tells a metered source from a lost one. The retry's own cadence, at most one small request a second while refused, is its pacing; the same reasoning as fix(live): the HLS ingest rides out an origin's refusals instead of ending the session #778, and like there, a ceiling the host declares (maxConcurrentSourceRequests) still binds it.
  • A live open whose first connection ended with any 4xx or 5xx and no data throws AVIOReaderError.httpStatus(status) at once, so publishLoadFailure reports sourceRateLimited or sourceRefused with the status and the host retries with the right words. A connection that ended with no status (a transport failure) still goes to the read loop.

Non-live sources keep the ladder, the count and the pacer exactly as they are.

Test plan

  • Device / OS: a Mac, macOS 26, swift test.
  • Source media: a loopback origin serving a raw MPEG-TS body with scripted statuses (LiveStreamRefusalRetryTests), and an IPTV panel's live MPEG-TS channel (H.264 + AAC, SDR) for the open and the steady state through aetherctl play --live --fast-zap --dvr-window 600 --max-concurrent-requests 1 on both the native and the software route.
  • Result:
    • LiveStreamRefusalRetryTests, new: the wait rule as a pure function (a second; Retry-After 2 → 2 s; Retry-After 30 → 5 s); with the reconnect backoff scaled down as the other reader suites do, three 509s mid-stream are retried until the stream continues, with no refusal on the origin's books and no pacer; a stream refused past the budget (shortened through a test seam) is asked again more than the old count allowed and then ends with EIO; a 509 and a 403 at the open throw typed on the first request. Judged by outcome and request count, not the wall clock: the first version of this PR bounded the gaps and CI's runner, three times slower than a Mac on this suite, failed them. The mid-stream, budget and open cases fail on main.
    • Full suite: 4,432 Swift Testing tests and the XCTests pass.
    • No field capture of a refused TS reconnect yet: the shape is the account-limit refusal fix(live): the HLS ingest rides out an origin's refusals instead of ending the session #778 measured on the HLS ingest, on the other reader a host uses for the same panel.

Checklist

  • CHANGELOG.md updated
  • Commit messages follow Conventional Commits (feat(...), fix(...), chore(...))
  • The fix lives in the engine, not in a host-side workaround
  • Public API changes are intentional and documented (none; liveRefusalRetryBudgetForTesting is internal)

…efusals

A connection-limited panel answers a live TS stream's reconnects with 509
while another device on the account holds its 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,
which the reload after it waited out (up to 10 s a request).

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; its refusals arm no pacer and lower no
budget, and the books still say the origin refused so the revive arm can
tell metered from lost. A live open the origin answers with a status
fails typed with that status at once, where the read loop used to spend
the open budget on the same request and hand back an untyped failure.

LiveStreamRefusalRetryTests.
jpsts added 2 commits October 16, 2026 04:11
…he wall clock

The three loopback cases bounded the gaps between the origin's requests
(a second each, Retry-After stretching them to 2 and 5 s, a count within
the budget), and CI's runner took three times as long as a Mac to run
the same suite, so the bounds decided what the tests reported. They now
run with the reconnect backoff scaled down, as the other reader suites
do, and assert what came through, how many requests it took, and that
the origin's books hold no refusal; the wait itself is a pure rule
(AVIOReader.liveRefusalWait) tested as one.
@superuser404notfound
superuser404notfound merged commit eb10eba into superuser404notfound:main Oct 16, 2026
4 checks passed
superuser404notfound pushed a commit that referenced this pull request Oct 16, 2026
…ils typed

#796 throws a live open's first 4xx/5xx typed at once. Before it, the read loop
gave a hard 5xx on a never-productive source one more attempt, which is what a
500 or 502 during a tuner or transcode spin-up needs. Refusals (429/503/509) and
4xx still fail at once. The live refusal budget's test seam is now read once at
reader init, like the other reader hooks, so a parallel test cannot change it
under a running reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJcXkGQrMiBPixVEFTSBJS
@superuser404notfound

Copy link
Copy Markdown
Owner

Merged, thank you. This closes the gap #778 left on the raw TS path: the same panel refusal is now ridden out on both readers, and a refused live open finally reaches the host with its status.

Follow-up on main in cc71691f:

  • A live open answered with a server error that is not a refusal (a 500 or 502, for example while a tuner or transcode spins up) is asked once more before it fails typed. Before this PR the read loop gave that case one more attempt, and the typed throw had removed it. 429/503/509 and 4xx still fail at once, as in the PR.
  • liveRefusalRetryBudgetForTesting is read once at reader init, like reconnectBackoffScaleForTesting, so a test running in parallel cannot change the budget under a reader that is already running.

One note for other integrators: a 509 at the open (a zap while the previous channel's slot is still held) now fails as sourceRateLimited right away instead of being retried inside the engine, so the retry is the host's job there. That matches what #779 does for the HLS join, and your host already does it.

It ships with the next release.

@superuser404notfound

Copy link
Copy Markdown
Owner

Released in 7.38.0. Thanks again for this.

@cmcpherson274

Copy link
Copy Markdown

A data point from an Apple TV on the typed refusal at a live open, which reached us in 7.38.0 and which 7.44.0 carries. When the origin answers a plain-URL live open with 503, the open now fails at once, as 7.38.0's release notes say; the kind our app reads afterwards is liveSourceUnavailable, not sourceRateLimited.

Setup. Apple TV 4K (3rd generation, AppleTV14,1), tvOS 27.0 (24J361), Debug builds of our app, 7.44.0 with none of its optional traits enabled; the full-screen runs were repeated on the tvOS 27.0 simulator. A live news channel from Jellyfin as a plain MPEG-TS URL (isLive = true), read by the engine's own HTTP reader through a pass-through relay of ours that answers every request with 503, with no Retry-After, for W seconds from the first one. Two places in our app open such a URL: the full-screen player's plain-URL path and a multiview tile. Each run in a fresh process, 20 to 21 October.

What we see, on every refused open: the reader's first connection ends with 503 after 4 to 20 ms ([SourceOpen] … result=ended … status=503), the engine logs

[AVIOReader] pump live open refused status=503; failing the open typed
[AetherEngine] probe failed (Origin answered HTTP 503 for the source); proceeding without criteria

and load throws DemuxerError.openFailed(code: -5) ("Demuxer: open failed (Input/output error (-5))") 0.01 to 0.09 s after the load began. Our app reads the engine's errorInfo and startupProgress as soon as load throws, before it stops the engine: the kind was liveSourceUnavailable and the checkpoint dispatched every time, and sourceRateLimited or sourceRefused never appeared.

where W runs refused opens liveSourceUnavailable at dispatched
full screen, Apple TV 5, 25, 60 s 2 each 22 22
full screen, simulator 5, 25, 60 s 2 each 22 22
multiview tile, Apple TV 5, 25 s 6 each 12 12

On full screen on the Apple TV, in a 25 s or 60 s window, our own three retries (after 1, 3 and 8 s) were refused at 1.19 to 1.29, 4.45 to 4.59 and 12.65 to 12.80 s after the first refused open, each failing typed, and there our app stopped; in a 5 s window the fourth open played. On 7.36.0 the same refusals were retried inside the reader: in a 5 s window the picture came 12.5 s after the tune, and longer windows ran into our own 20 s start limit.

The sentence in 7.38.0's release notes this bears on: "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." In 7.44.0's source the typed error stops at the probe's catch (AetherEngine.swift:4572-4575), and the live fail-fast after it (:4702-4704) publishes liveSourceUnavailable with no underlying error. The open PR #818, as you reviewed it on 20 October, touches this classification; these runs are of the released 7.44.0.

We have chosen to keep the engine's new behaviour as it is in our app; this is only a note for hosts that key a retry on sourceRateLimited.

7.44.0 is our app's engine pin as of today (2026-10-21).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants