Recover failing HLS sessions
without guessing.
A working guide to the failure patterns Streamwake catches on real HLS sessions — from master playlist fetch through EXT-X tag continuity and AES key delivery — written so an on-call engineer can read it next to a recent chunklist capture and act on it.
What Streamwake checks
Five families of probes, each with a small, deterministic pass/fail verdict that feeds the timeline. Every check has a name — that's the name you'll see on the agents feed.
- Master playlist returns HTTP 200 within the probe budget (default 5s).
- Content-Type reads as application/vnd.apple.mpegurl — not octet-stream.
- #EXTM3U on line 1; EXT-X-VERSION matches features used; no BOM.
- Every STREAM-INF variant URI resolves to a media playlist.
- Each variant playlist has a coherent EXT-X-TARGETDURATION / MEDIA-SEQUENCE pair.
- #EXT-X-DISCONTINUITY only at genuine PTS / codec / format transitions.
- MEDIA-SEQUENCE advances window stride of EXTINF.
- EXT-X-PROGRAM-DATE-TIME monotonic across the last 6 segments.
- Segment duration drift: declared EXTINF vs actual bytes.
- STREAM-INF BANDWIDTH ascending across every variant.
- CODECS strings cover every audio group referenced via AUDIO=.
- RESOLUTION matches rung ordering on the CDN edge.
- EXT-X-KEY URI reachable for the current MEDIA-SEQUENCE (AES-128 and SAMPLE-AES).
- For fMP4: PSSH box present in every #EXT-X-MAP init segment.
- Key rotation: new keyid live before the rolling segment references it.
A minimal-but-real pair of playlists
A master with three video variants and one audio rendition group, plus a media playlist for the 720p variant. Annotations below name the probe that reads from each line — so when an alert fires, you know which leaf of the playlists to look at first.
The master playlist #EXTM3U declares one #EXT-X-MEDIA audio rendition group, then three #EXT-X-STREAM-INF lines — bandwidth ascending, RESOLUTION ascending, CODECS spanning audio + video.
The variant playlist carries its own #EXTM3U and #EXT-X-VERSION:6. #EXT-X-MAP points at the fMP4 init segment (the proxy for the drm.pssh_present probe); each segment has a matching #EXT-X-PROGRAM-DATE-TIME so the segment_tail_window probe can check monotonicity.
- playlist.reachable → root
#EXTM3U - variant.consistency →
#EXT-X-STREAM-INFURI per variant - segment.tail_window → trailing
#EXTINF+#EXT-X-PROGRAM-DATE-TIME - media.discontinuity_resolution →
#EXT-X-DISCONTINUITYplacement - drm.key_reachable →
#EXT-X-KEY URI=for current MEDIA-SEQUENCE
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-aac",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,URI="audio-aac/playlist.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=628000,CODECS="mp4a.40.2,avc1.640028",RESOLUTION=640x360,AUDIO="audio-aac"
video-360p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2428000,CODECS="mp4a.40.2,avc1.640028",RESOLUTION=1280x720,AUDIO="audio-aac"
video-720p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4828000,CODECS="mp4a.40.2,avc1.640028",RESOLUTION=1920x1080,AUDIO="audio-aac"
video-1080p/playlist.m3u8#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:1470
#EXT-X-MAP:URI="video-720p/init.mp4"
#EXT-X-PROGRAM-DATE-TIME:2026-08-06T10:42:13.000Z
#EXTINF:6.0,
video-720p/seg-1470.m4s
#EXT-X-PROGRAM-DATE-TIME:2026-08-06T10:42:19.000Z
#EXTINF:6.0,
video-720p/seg-1471.m4s
#EXT-X-PROGRAM-DATE-TIME:2026-08-06T10:42:25.000Z
#EXTINF:6.0,
video-720p/seg-1472.m4s
#EXT-X-PROGRAM-DATE-TIME:2026-08-06T10:42:31.000Z
#EXTINF:6.0,
video-720p/seg-1473.m4s
#EXT-X-PROGRAM-DATE-TIME:2026-08-06T10:42:37.000Z
#EXTINF:6.0,
video-720p/seg-1474.m4s
#EXT-X-PROGRAM-DATE-TIME:2026-08-06T10:42:43.000Z
#EXTINF:6.0,
video-720p/seg-1475.m4sTen ways an HLS session fails
Each row: symptom the agent reports → the underlying cause → a fix that holds under the next probe cycle. The named probes are what you'd grep for in the agents feed.
curl returns 200 with the right bytes; the player throws "MANIFEST_PARSE_ERROR" before playback starts.
Content-Type is wrong (text/plain or application/octet-stream) or the file has a UTF-8 BOM before #EXTM3U. The reachable probe marks the fetch green but the well-formed probe flags the parse.
Set Content-Type: application/vnd.apple.mpegurl on the origin response; emit the playlist as UTF-8 with no BOM and a leading #EXTM3U on line 1.
Player drops without error, or parser rejects live-only tags like #EXT-X-PROGRAM-DATE-TIME.
You used EXT-X-VERSION:6 features in a playlist declared as VERSION:3. Older parsers silently drop tags; aggressive ones refuse to play.
Set EXT-X-VERSION to the highest version any tag in the playlist requires (3 + MAP, 4 + byte-range, 6 + fMP4 CMAF + PDT). The well-formed probe will flag underspec.
ABR oscillates between rungs; logs show UP_SWITCH / DOWN_SWITCH within seconds of the first segment.
#EXT-X-STREAM-INF BANDWIDTH values are not monotonically increasing — the 720p row sits after the 1080p row by accident. Safari/AVPlayer honors insertion order, not magnitude.
Sort variant lines ascending by BANDWIDTH; ladder.bandwidth_order will flip to pass at the next probe.
Video plays but audio falls back to silence on certain players; some render audio-only without video.
CODECS="avc1.640028" on the video variant but the audio rendition group adds mp4a.40.2 elsewhere — STREAM-INF omits the audio codec. Players that build the source buffer from STREAM-INF alone will drop the audio track.
List the full codec set in CODECS="mp4a.40.2,avc1.640028" on every variant that references the audio group.
Player stalls at every segment boundary; timeline jumps visibly in the seek bar; ABR locks to the lowest rung.
Packager emits #EXT-X-DISCONTINUITY before every #EXTINF even though PTS is continuous. Players close and reopen the SourceBuffer on every discontinuity, blowing the buffer budget.
Emit #EXT-X-DISCONTINUITY only on actual PTS / codec / format transitions. media.discontinuity_resolution will stop flagging each segment.
Timestamps a viewer sees in the seek bar move backwards; CDN returns 304-not-modified for a chunklist that should have advanced.
PDT regeneration lost the live anchor — the packager restarted its wall clock from origin time instead of the live edge. CDN sees the chunklist unchanged and serves a stale body.
Pin PDT regeneration to the live edge (not origin stat-time); have the agent tail_window probe assert PDT monotonicity across the last 6 segments.
Player underestimates when the next segment lands; tail-window probe shows pass but viewers report a hard pause near the live edge.
Packager is producing 6.2s segments because of GOP boundaries; playlist still declares #EXTINF:6.0. Player schedules the next fetch 200ms too early and stalls before the segment is on the wire.
Either coerce the packager to honor the declared duration, or surface real segment durations in EXTINF so the player uses the actual figure.
Player picks a variant and MSE throws SourceBuffer.appendBuffer with a mimeType mismatch on a subsequent rendition switch.
One rendition is packaged as .ts, another as fMP4 with #EXT-X-MAP. MSE was appended with the wrong codec string and the switch rejects the next segment.
Align the packaging across every variant — either all fMP4/CMAF, or all TS — and update the variant TYPE parameter consistently. cmaf.ftyp_match will start passing.
Playback works for the first key rotation, then one variant throws "KEY_LOAD_ERROR" and the player aborts that rendition.
EXT-X-KEY URI references a per-region keyserver that rebalances the key id across the rotation; the new key id is unavailable on the new origin. Subsequent segments cannot be decrypted.
Surface the EXT-X-KEY URI as part of the probe — drm.key_reachable should hit the URI bound to the current MEDIA-SEQUENCE before each rotation.
Player keeps the same dead media sequence even though new segments have landed; ABR locks at the bottom rung.
CDN honored If-Modified-Since with the prior manifest timestamp even though the live window advanced. Master playlist 304s; media sequence never moves forward.
Set Cache-Control: no-cache, must-revalidate on the master; pin Cache-Control: max-age=1 on the variant playlists. The reachable probe flags 304s over a moving window.
Diagnose with Streamwake
Register the HLS source against POST /api/v1/streams, then read the agent timeline back through GET /api/v1/agents. The probe verdicts in the timeline are exactly the rows above.
The curl below registers an HLS master playlist URL and asks for a 30-second probe cadence. The cookie is the same better-auth.session_token that gates every /api/v1/* call — see the auth guide for how to mint one.
Once the stream is registered, the agents endpoint returns the per-probe verdicts below. The order of checks mirrors the probe families in the section above — playlist.reachable first, variant continuity next, segment windows and ladder after, then CMAF and DRM.
curl -X POST https://streamwake.polsia.io/api/v1/streams \
-H "content-type: application/json" \
-b "better-auth.session_token=<your-session-cookie>" \
-d '{
"sourceUrl": "https://cdn.example.com/live/event/manifest.m3u8",
"protocol": "HLS",
"probeIntervalSeconds": 30
}'curl https://streamwake.polsia.io/api/v1/agents?stream_id=<id> \
-b "better-auth.session_token=<your-session-cookie>"{
"stream_id": "cklivehlsevent456",
"source": "https://cdn.example.com/live/event/manifest.m3u8",
"protocol": "HLS",
"checks": [
{
"probe": "playlist.reachable",
"result": "pass",
"latency_ms": 118,
"detail": "content-type application/vnd.apple.mpegurl"
},
{
"probe": "playlist.well_formed",
"result": "pass",
"detail": "EXT-X-VERSION=6, MASTER with 3 variants + 1 audio group"
},
{
"probe": "variant.consistency",
"result": "pass",
"variants_checked": 3
},
{
"probe": "segment.head_window",
"result": "pass",
"latency_ms": 96,
"segments_checked": 6
},
{
"probe": "segment.tail_window",
"result": "fail",
"latency_ms": 4014,
"detail": "EXT-X-PROGRAM-DATE-TIME drift: PDT moved backwards at seg-1474 (10:42:31Z -> 10:42:29Z), CDN served stale chunklist"
},
{
"probe": "media.discontinuity_resolution",
"result": "pass",
"discontinuities_checked": 0
},
{
"probe": "ladder.bandwidth_order",
"result": "pass"
},
{
"probe": "cmaf.ftyp_match",
"result": "pass",
"detail": "audio/video init segments share compatible ftyp brand set"
},
{
"probe": "drm.key_reachable",
"result": "pass",
"detail": "AES-128 key URI returned 200, valid for current MEDIA-SEQUENCE"
}
]
}Want Streamwake to catch this on its own?
Sign up, register a stream, and the same probes that produced the timeline above run on every refresh — and surface in a Slack channel, a webhook, or the streams dashboard.
- Stream list reads from
GET /api/v1/streams; per-stream timeline fromGET /api/v1/agents. - Probe verdicts stream into the dashboard within one cadence interval — manual curl not required.
- Self-serve signup at /sign-up — no sales call required for the first stream.