The HLS init segment is served empty when it is the first request of a session #132
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
GET …/hls1/main/-1.mp4— the fMP4 initialisation segment — answers 200 with a zero-byte body when it is the first request of a transcoding session. The same URL after segment 0 has been fetched answers the full 1,364 bytes. Reproduced deterministically twice with fresh sessions, while verifying #94.#EXT-X-MAPis the first thing every HLS player fetches, so a real client hits this on the opening request of every new session. An empty init segment means nomoov, so the player has no codec configuration and the stream fails before a frame is decoded.It survived M5's browser run by timing alone — that run fetched the playlist, then segment 0, then the map, in an order that happened to work.
The cause
Ffmpeg::wait_for(crates/jellyfin-ffmpeg/src/transcoder.rs:196) returns ontry_exists— the instant ffmpeg creates the file, not when it has written it. The init segment is then handed tofiles::serveand sent as a static file, so the route serves the zero bytes that exist at that moment. A segment is streamed as it grows and so does not show this; the init segment is the one file served statically.It is the same shape as the bug M5 already records — "a joined job answered before its segment existed" — one file over. That one was fixed by making the join wait for the file; this one waits for the file and then does not wait for its contents.
Where to start
crates/jellyfin-ffmpeg/src/transcoder.rs:196and whatever serves-1.mp4incrates/jellyfin-http/src/routes/hls.rs. The question to settle from upstream rather than by choosing: what doesDynamicHlsControllerwait for before it hands over the map — file existence, a non-zero size, or the muxer having flushed it? Upstream has the same race and evidently does not lose to it.Whatever the rule is,
wait_forreturning on creation is too weak for any caller that then reads the file in one shot.How to know it is done
A fresh session's very first request being the init segment answers the same bytes as the reference does for the same file, and a test drives that order specifically — the order matters and is the whole bug, so a test that fetches a segment first would pass while the defect stood.
House rules
Tests run under
cargo nextest; upstream's source is the checkout inupstream/at the pin; generated artifacts are regenerated, never edited — a failing corpus case is the port disagreeing with upstream, not the corpus being wrong. Comments say what the code does, not how it got there. The shared reference on:8096is read-only; anything needing a write wants its own container from the pinned image.Done, on
main—b48a9e6andb3080e6.Upstream's rule, which is what this ports
DynamicHlsController.GetSegmentResult, and it is stated in a comment there:So existence was never upstream's test either. A file is there before it holds anything — a muxer opens its output before it has something to put in it — and what says a file may be handed over is the encoder having moved past it. Upstream has two ways to know that: the job has exited, or the next file exists. It also short-circuits when the requested index is behind the transcoding position, on the reasoning that you cannot transcode backwards.
That is now the rule here.
segment::whole_oncenames the file whose existence says the one being asked for is whole, and the route waits for that rather than for the file itself. For the fragmented header the "next" file is the segment the job was started at, which is not the header's own index — the one place the arithmetic is not simplyindex + 1.Ffmpeg::wait_forkeeps returning on creation, and its doc now says why that is enough for one caller and not the other: a route streaming a file as it grows needs no more, and one reading it in a single response needs its own evidence. That is the distinction the bug turned on — the init segment is the only file served in one shot.The test is the point
The order is the whole defect, so a test that fetched a segment first would have passed while the bug stood.
a_map_asked_for_before_any_segment_is_served_with_what_the_muxer_put_in_itasks for…/hls1/main/-1.mp4as the first request of a fresh session.It bites: reverting only the two source files and re-running gives
and it passes with them. The stand-in encoder in
transcoding_fixturenow behaves like a real muxer — it opens the header empty, fills it when the first segment is flushed, and writes a second segment after that — which is what makes the race reproducible in a test rather than only against ffmpeg.Numbers
JELLYFIN_RS_REQUIRE_FIXTURES=1 cargo nextest run --workspaceonmain: 2190 passed, 0 skipped. Clippy clean.What is not established
No client drove it. The fix is verified by the ordering test and by the ported rule matching upstream's, not by a player. A browser run was not attempted, because playback stalls on this box for an unrelated reason recorded during #94 — the client stops after
PlaybackInfoand issues no/Videosrequest at all, with the renderer intermittently freezing — so a browser could not have exercised this path either way. The original reproduction that produced this issue wascurlagainst the real routes in the real order, twice, deterministically.The other callers of
wait_forwere not audited. The two commits change the one that reads a file in a single response; whether any other caller has the same weakness is unchecked, and the doc comment now at least states the property a caller needs so the next one has something to check itself against.This landed with its implementer's session cut short partway through; the fix and its test were complete and are what is described above, and the remaining audit is named rather than quietly dropped.