Skip to content

fix(prewarm): a warm hands over its resolved target, its Matroska tail, and no credentials (AE#551) - #584

Merged
superuser404notfound merged 1 commit into
mainfrom
fix/prewarm-redirect-handoff
Sep 21, 2026
Merged

superuser404notfound merged 1 commit into
mainfrom
fix/prewarm-redirect-handoff

Conversation

@superuser404notfound

Copy link
Copy Markdown
Owner

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.py shape from the aetherctl skill: 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_header and not for the session:

  • HLSVideoEngine seeks 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_seekhead parses 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. SourcePrewarmPlan now 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 Authorization and X-Emby-Token on the load: the 302 arrived auth=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 its Task {} enqueued on the main actor that the semaphore below it was blocking: one thread, zero requests, no output. Fixed with Task.detached.

Tests

Issue551RedirectHandoffTests (4 tests) and the rewritten Matroska half of SourcePrewarmPlanTests. 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 == resolvesForTheWarm and both credential expectations), and swift test is 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

…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
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.

1 participant