fix(prewarm): a warm hands over its resolved target, its Matroska tail, and no credentials (AE#551) - #584
Merged
Conversation
…l, and no credentials (AE#551) Three findings from alsoeoe's round 2 report, measured against a logging origin with a 302 resolver in front of it (loopback answers a request count honestly even where it cannot answer a timing). **The resolved redirect target was thrown away.** The warm followed the resolver's 302 and the session then resolved the same chain again, once per fresh connection rather than once: five resolver hops in one 8 s session on the harness, 800 ms each, and ~3.2 s on the reporter's panel. The warm now carries the URL that answered and the adoption pins it (#12), so the expiry ladder that already exists handles a lease that has run out. The warm's own tail request rides the same target instead of re-entering through the resolver. **Matroska was excluded from the tail on a premise that only covers `read_header`.** The session reads near EOF right after the open whatever `read_header` did: `HLSVideoEngine` seeks to the middle of the title so libavformat loads the index ("1-2 byte-range reads", its own comment), and `matroska_execute_seekhead` parses every non-Cues object the SeekHead points at on the spot. A warmed 63 MB MKV still spent a request on `bytes=63704296-63708626`, its Cues element to the byte. `SourcePrewarmPlan` now reads the SeekHead and warms from the earliest object past the head to the end of the source, capped at 1 MB, Clusters excluded. Measured on the same fixture: the cue prewarm seek went from 903.3 ms to 0.1 ms. **A pinned cross-origin target was sent the credentials the redirect hop had stripped.** `RedirectHeaderPolicy` (#126) only ever ran on the hop, while every later request was built straight against the pinned target with the full header set. Measured: the 302 arrived `auth=none`, and the post-seek request to the same pinned host 13 s later carried both `Authorization` and `X-Emby-Token`. The policy now runs where the request is built, so a pin cannot outflank it, and a non-credential header (#8) still travels. Also: `aetherctl play --prewarm` could never run. Top-level code is MainActor-isolated under the Swift 6 language mode, so its `Task {}` enqueued on the main actor that the semaphore below it was blocking. The documented way to measure this feature deadlocked, which is why none of the above was caught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015PM3xUJB6ZQyqnmGK1fp6F
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Round 2 of AE#551, from alsoeoe's report, plus one finding that turned up while fixing it.
Measured against a Range-logging origin with a 302 resolver in front (
scratchpad/origin.pyshape from theaetherctlskill: a loopback origin answers a request COUNT honestly even where it cannot answer a timing), on a 63 MB ffmpeg MKV whose Cues sit 4331 B before EOF.1. The warm's resolved target was discarded (reported)
The warm follows the resolver's 302; the session then resolved the same chain again, and once per fresh connection rather than once.
Before, one 8 s session: 5 resolver hops at 800 ms each. After: 1, the warm's own head request, which is where the chain is learned.
The warm carries the URL that answered, and adoption pins it through
recordResolvedURL(#12), so an expired lease falls back to the source URL on the ladder that already exists. The warm's tail request rides the same target instead of re-entering through the resolver.2. Matroska needed the tail after all (reported, different cause)
The premise here was that Matroska reads no cues at open. That holds for
matroska_read_headerand not for the session:HLSVideoEngineseeks to the middle of the title right after the open so libavformat loads the index, priced in its own comment as "1-2 byte-range reads";matroska_execute_seekheadparses every non-Cues object the SeekHead points at on the spot, deferring only the Cues.So a warmed MKV still spent a request on
bytes=63704296-63708626, which is its Cues element to the byte.SourcePrewarmPlannow reads the SeekHead and warms from the earliest object past the warm head to the end of the source, capped at 1 MB, Clusters excluded (a Cluster is media, and warming from the first one to EOF is a download).Same fixture, same origin: the cue prewarm seek went from 903.3 ms to 0.1 ms, and the session issues no trailing request at all.
3. A pinned cross-origin target got the credentials the hop had stripped (found here)
RedirectHeaderPolicy(#126) ran on the redirect HOP only. Once a session pinned that target (#12), every later request was built straight against it with the full header set, so the credential the hop had just stripped went to the edge on the next range anyway.Measured with
AuthorizationandX-Emby-Tokenon the load: the 302 arrivedauth=none, and the post-seek request to the same pinned host 13 s later carried both. The policy now runs where the request is built (AVIOReader.headers(for:)), covering the pump, the tail prefetch, the held connection, the probe, the detour and the streaming path. Non-credential headers (#8) still travel.Why none of this was caught
aetherctl play --prewarm, the documented way to measure this feature, deadlocked on its first call. Top-level code is MainActor-isolated under the Swift 6 language mode, so itsTask {}enqueued on the main actor that the semaphore below it was blocking: one thread, zero requests, no output. Fixed withTask.detached.Tests
Issue551RedirectHandoffTests(4 tests) and the rewritten Matroska half ofSourcePrewarmPlanTests. The origin test helper now records request headers, because a credential that must not reach a target is only provably absent AT the target.Both defects fail with the fix backed out (before arm:
source.rangeRequestCount == resolvesForTheWarmand both credential expectations), andswift testis green on both runners: 641 XCTest cases, 3227 swift-testing, real exit 0.Not in this PR
The warm's 8 MB head is dropped by the cue prewarm's mid-file read and then re-fetched by playback's first backward read: two 4 MB requests for bytes the warm already held. Measured in the same log, pre-existing, and its own issue.
🤖 Generated with Claude Code
https://claude.ai/code/session_015PM3xUJB6ZQyqnmGK1fp6F