Repository navigation
fix(diagnostics): preserve source failures and request ownership - #818
Conversation
superuser404notfound
left a comment
There was a problem hiding this comment.
Thanks for this, the cause-preservation work is a real improvement: keeping the historical NSError domains and case codes while carrying the FFmpeg code separately, terminalError staying source compatible, zero cost while no handler is installed, and the retired ranged connection no longer poisoning its forward-only replacement are all things I want in the engine. A few points before it can go in, roughly in order of weight.
1. Rebase onto main. The branch conflicts in CHANGELOG.md, AetherEngine+ReloadWithOptions.swift and LiveStreamRefusalRetryTests.swift. The flaky give-up test was already fixed on main in #809 (2394ab2e: the refusal budget is passed per reader and the give-up case is bounded from below instead of counting requests), so b9169f38 should probably be dropped in favour of main's version rather than merged on top of it. If something in it is still needed after the rebase, a short note on what main's fix does not cover would help.
2. A failed live start should stay .liveSourceUnavailable. Before this PR, a live probe that burned its reconnect budget (AetherEngine.swift live fail-fast) and a transport failure in the live HLS ingest both published .liveSourceUnavailable. Now both go through publishLoadFailure, so a timeout or a refused connection arrives as .sourceOpenFailed. Hosts branch on that kind: Sodalite maps .liveSourceUnavailable to its localized "live channel unavailable" screen and would fall back to a generic engine-classified error for every dead channel. api.md treats the kinds as API, and a host cannot tell this move apart from a new failure class.
I'd suggest keeping the new detail without moving the kind:
- HTTP status:
.sourceRefused/.sourceRateLimitedwith the status (this matches what the live HLS path already did, fine as is). - TLS trust:
.sourceCertificateRejected(also fine). - Everything else on a live start:
.liveSourceUnavailable, with the original URL error inunderlyingDomain/underlyingCode.
The test "Live initial connection failure keeps the URL error instead of generic live unavailable" would then assert the kind plus the preserved domain and code.
3. The diagnostics context does not reach AVIOReader requests. The HLS readers store their context explicitly, but AVIOReader never receives one: every delegate (Probe, ChunkFetch, PersistentRead, Streaming, TailPrefetch) reads the ambient task-local when it is constructed. The size probes run on Thread.detachNewThread (around AVIOReader.swift:4438 / 4451), and reconnects, prefetch and chunk fetches run on the FFmpeg read thread or the prefetch threads, where the task-local is nil. So on the progressive path, which is the most common one for a Jellyfin direct play, most events arrive without a context. Capturing SourceNetworkDiagnostics.context once in AVIOReader.init (or passing options.sourceDiagnosticsContext where the reader is built) and handing it to each delegate would make it hold, ideally with a test that drives a reconnect and checks the context on the second request.
4. diagnosticReason matches on message text. Rewording any playlistInvalid(reason:) or segmentDecryptFailed(reason:) string silently drops it into the fallback bucket. A test that collects the reason: literals thrown in Sources/ and checks that each maps to a non-fallback value would catch that drift. Small thing, but the whole point of the field is that it stays stable.
5. Key fetch refusals. An HTTP failure on the AES key now throws playlistUnreachable(status:) instead of segmentDecryptFailed. Publishing it as .sourceRefused with the status seems right to me, but the public terminalError then calls a key refusal a playlist one. Either a word in the doc comment for playlistUnreachable or a note in the changelog would do.
6. Public Event shape. role and phase are free Strings, and several fields are public vars on a type hosts only ever read. String-backed structs like PlaybackErrorKind (stable raw values, room to add) and let fields would make this easier to keep stable. Raw URLs in the event are fine given the documented redaction requirement.
Happy to take another look once it is rebased. Points 2 and 3 are the ones that block; 4 to 6 could also land as follow-ups if you prefer.
ad4b342 to
7d643f2
Compare
|
Thanks for the detailed review! I've addressed all six points and rebased onto main, keeping the existing #809 fix. Failed live starts retain liveSourceUnavailable and the underlying cause, and request context now survives AVIO thread hops and reconnects. The diagnostic contracts, documentation, and regression coverage have also been updated. All four CI checks are now passing. Thanks again for catching these compatibility and attribution issues! |
superuser404notfound
left a comment
There was a problem hiding this comment.
Thanks for the thorough follow-up, and for keeping the #809 fix intact through the rebase. Re-reviewed at 7d643f26:
Resolved
- Live start keeps
.liveSourceUnavailable(HTTP and TLS answers still type assourceRefused/sourceRateLimited/sourceCertificateRejected), with the cause carried inunderlyingCode. AVIOReadercaptures the context at init and hands it to every delegate explicitly, so the FFmpeg and URLSession threads no longer depend on the TaskLocal. The reconnect test covers it.- Reason literals have a drift test, the key 403 stays distinct, and
Role/Phaseare openRawRepresentablestructs on an immutableEvent.
One new blocker: a transport failure at open no longer reaches the reconnect ladder
failIfStreamingRefused now also throws the connection's URL error when nothing arrived (AVIOReader.swift:1686-1692), and the live open calls it on the no-data path (:1407). On main a live open whose first connection ends without a status falls through to the read loop, which rides it out; the comment on refusalOfLiveOpenConnection (:1630) still says exactly that. With this change, a first connection that is reset or times out (an IPTV or Jellyfin tuner still spinning up does this) fails the load on the spot instead of reconnecting.
The same check runs on the sequential and forward-only VOD opens (:1293, :1426, :1479, :1530), which is why two existing tests had to change: SequentialOriginReaderTests.shortBodyReportsEIO now accepts URLError.networkConnectionLost from open(), and StreamingBufferBoundTests.droppedConnectionHasNoCause switched its fixture from serveThenDrop to serveThenGoSilent. Those tests pinned the reconnect behaviour, so they should stay as they were.
Suggested shape: keep the URL code as diagnostic data only. Record it, and when the reader finally gives up (the live probe fail-fast in AetherEngine.swift, or the read loop's existing give-up) attach it to the error that is thrown then, instead of throwing it from the open. That keeps "Live initial connection failure keeps liveSourceUnavailable and the original URL error" true without changing when the engine gives up. The TLS branch (-1206 ... -1200) can stay as is, since a trust failure cannot be fixed by reconnecting.
Housekeeping
CHANGELOG.md conflicts with main again after the #847 merge; everything else merges cleanly. Happy to resolve that one myself at merge time.
Failed initial live probes keep liveSourceUnavailable with the original URL error in underlyingDomain/underlyingCode; HTTP and TLS refusals stay typed. AVIO readers capture the diagnostics context at init and hand it to every delegate. HLS reasons are finite and pinned by a literal drift test, refused AES keys keep playlistUnreachable(status:), and source network events are immutable with open Role/Phase values. A transport failure at open is not a reason to fail it: the open and the read loop ride resets and timeouts out on the reconnect ladder as before, and the reader only records the last URL error. The live start attaches that record when it finally gives up (superuser404notfound#796's typed status path is kept). Only a TLS trust failure, which no reconnect can fix, fails the open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7d643f2 to
b794e04
Compare
Bring the PR superuser404notfound#818 second-review fix (b794e04) into develop. A reset or timeout at open no longer fails it: the open and the read loop ride the failure out as upstream does, and the reader only records the last URL error. A failed live start attaches that record to liveSourceUnavailable when it gives up; a TLS trust failure still fails the open. The two reconnect regressions return to their upstream form, and a live open whose first connection resets after its header now pins the behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for catching that. Updated at
Scoped local runs (source failure diagnostics, sequential/forward-only reconnects, HLS diagnostics, PlaybackErrorInfo, live refusal retry, TLS classification, documentation tests): 95 passing. |
A held HTTP/1 open that failed after a response head (an oversized declared body, a malformed answer, a redirect limit) recorded its terminal event against the original URL and without a status. It now reports the hop that answered, so a failure behind a redirect names the redirect target, with the status its head carried. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retire the fork-only held HTTP/1 event shape (completed + cancelled for abandonment, drain and discarded bytes counted) in favour of PR superuser404notfound#818's reviewed contract, plus the failing-hop fix prepared for upstream on codex/held-http-failed-open-evidence (b70de87). Develop now carries no engine change that is not in an upstream branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
One small addition on top ( The |
aa0bf3b
into
superuser404notfound:main
lastTransportFailureCode was inserted between the comment and the property it describes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJcXkGQrMiBPixVEFTSBJS
|
Merged as The branch conflicted with today's #859 / #860 merges ( It goes out with the next engine release. |
Merge published 7.48.0. It contains PR superuser404notfound#818 (merged as aa0bf3b) with everything develop carried beyond 7.47.0, so the tree now matches 7.48.0 exactly apart from MODIFICATIONS.md and COPYING.GPL3. The engine has no fork-only change left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Preserve source failure causes and keep request diagnostics attached to the load that created them. Retiring a failed ranged request also clears its stored failure so a healthy forward-only replacement can open.
What changed
liveSourceUnavailable, with the original URL domain/code. HTTP refusals and TLS trust failures retain their specific classifications.RoleandPhasetypes without changing their raw tokens. Hosts still own URL redaction.playlistUnreachable(status:)also covers segment/key HTTP refusals.7.45.0). Drop the separate refusal-test workaround in favor of main's fix(tests): LiveStreamRefusalRetryTests no longer flakes: per-reader budget and backoff #809 fix; dependency declarations and that test now match upstream.These changes remain together because the failure cause and request owner must survive the same open/replacement lifecycle. No retry budgets, TLS policy or host fallback policy are changed.
Test plan
6f1608d7, 62 scoped unit tests passed (35 XCTest + 27 Swift Testing), with all four production traits enabled. The package and ExampleSources compiled; documentation links/anchors passed.ad4b342d; its targeted unit test passed against the same reader/test sources in the 7.45.0 fork integration. An unchanged upstream tail-prefetch test hit its 4 s HTTP timeout under runner load on the next CI run. The identical tree at7d643f26passed all four checks on GitHub Actions: macOS default/all-traits tests and both single-thread runs, iOS/tvOS/visionOS builds, runtime regressions and documentation links. No timing assertion was weakened.Checklist
CHANGELOG.mdupdated