The render endpoint's action: everything between "every check passed" and bytes on the socket.
What runs first is the source metadata the chain could not check earlier.
AudioProxy.Source.stat/1 answers a missing (or non-regular, or
gone-unreadable) source with the same generic 404 as an unauthorized one,
and a source whose size exceeds AP_MAX_SRC_BYTES with 413. A source of
unknown size passes — what bounds it then is AP_MAX_VARIANT_BYTES, the
render pipeline's cap on retained output, per the
AudioProxy.Source.Type contract.
Then the audio-only gate: one ffprobe header read through
AudioProxy.Ffprobe, and a source carrying a genuine video stream is 415
before a render slot is asked for. Cover art is not video, and the whole
argument for rejecting rather than stripping is in
openspec/changes/add-audio-only-policy — briefly, -vn alone would make
this a free audio-extraction service for arbitrary video, at video's cost
profile and video's CVE surface. The gate's placement is the load-bearing
part: after the cache lookup, so a HIT never pays for it, and before the
semaphore, so a refused source cannot occupy a slot. Everything the gate
cannot see inside is still covered downstream — every argv carries
-vn -sn -dn and a protocol whitelist (AudioProxy.Ffmpeg.Command).
Then the render: AudioProxy.Source.ffmpeg_input/1 says what ffmpeg should
read, AudioProxy.Ffmpeg.Command.build/3 says how, and
AudioProxy.RenderCoordinator.subscribe/2 runs it — or attaches to the one
already running for this cache key — with this process as subscriber. The
filesystem is never reached around that seam: ffmpeg_input/1 is what
re-checks that the target is still a regular file, and a FIFO handed to
ffmpeg blocks forever on a read that never completes, holding a render slot
until the timeout.
Three requests that never render
An If-None-Match matching the URL-derived ETag answers 304 before the
stat — the ETag is the cache key, a pure function of the URL, so
revalidation is pure computation. A cache key already in the variant store
is served from it by AudioProxy.VariantCache, which owns everything about
a HIT and is the reason this module's streaming loop only ever reports
MISS or COALESCED. And a HEAD runs the full check chain including the
stat but ends bodiless after it, with no subprocess. All three sit after the
signature plug by pipeline order, so none is an existence oracle for
unsigned probes.
Two consequences worth stating, because both look like bugs and are not:
A HEAD answers the status the check chain can determine, which is not always the status a GET answers. Both 415s need a subprocess — ffmpeg diagnoses an undecodable source while decoding, ffprobe diagnoses a video one at the gate — so a HEAD on either answers 200 where the GET answers 415. It cannot do better without doing the work HEAD exists to skip; the same holds for 500 and 504. Everything the chain can know — 401, 404, 413, 422 — is identical to the GET. (The gate is deliberately not moved into the HEAD path to close the video half of this: HEAD would then be the one request whose cost is a subprocess, and clients use it precisely because it is cheap.)
And the 304 outranks the stat, so a revalidation for a variant whose source has since been deleted answers 304, not 404. That is deliberate: the ETag names immutable variant bytes, and a cache still holding them is not wrong to keep them. Moving the check after the stat would buy a more "honest" 404 at the cost of a stat on every revalidation — the cost this path exists to avoid.
Coalescing, from this side
Subscribing hands back a status and a backlog. :miss means this request
started the render and the backlog is empty; :coalesced means another
request is already rendering this exact variant, and the backlog is
everything it has produced so far — written as the first chunk, before the
live ones. Those are the two X-Audio-Proxy values §5 defines for a render;
HIT belongs to the request that never got this far.
Nothing else in this module knows about it. The coordinator broadcasts the pipeline's own message contract with itself as the handle, so the loop below is the loop that was written against the pipeline directly.
The streaming loop
A plain receive loop, not a GenServer: the conn belongs to this process and
stays here. Each {:chunk, _, data} is written with Plug.Conn.chunk/2;
{:done, _, _} ends the response; {:error, _, failure} is mapped by
class through AudioProxy.ErrorJSON (classifying is the pipeline's job,
choosing a status is this module's).
There is no acknowledgement to send. The pipeline's bounded buffer is released by the coordinator, which retains every byte anyway; what bounds memory on this path is the coordinator's retention cap.
Before and after the first byte
These are two different worlds, and the split is the whole shape of this
module. Before the first byte nothing has been sent, so any failure is still
an ordinary JSON error response — 404, 415, 429, 500, 504. After the first byte
the status line is spent: a failure can then only be signalled by tearing the
connection down without the terminating chunk (§5: nothing better exists over
plain HTTP), which is what abort/3 does by exiting.
Client disconnect is detected the way any writer detects it — the next write
fails. chunk/2 answering {:error, _} means the socket is gone, so this
request unsubscribes on the spot; the render stops only if it was the last
one listening. That is not the only guarantee: the coordinator monitors its
subscribers and the pipeline monitors its consumer, so a crash on this path
costs an ffmpeg process no more than a clean exit does. Detection is bounded
by chunk cadence rather than by wall clock, which is what an encoder
producing output continuously makes acceptable.
The receive deadline is a backstop, not the timeout
AP_RENDER_TIMEOUT is enforced by AudioProxy.Ffmpeg.Render, which owns the
subprocess and reports %{class: :timeout}. The deadline below is the same
budget applied to this process' mailbox, so that a render which dies
without saying anything at all cannot leave a request hanging. It is set a
little wider on purpose: the pipeline's timer starts at spawn and this one
restarts on every message, so the pipeline's own timeout is what a client
normally sees, with its classification intact.
That claim holds for every format that streams, which is every format but
one. f:peaks produces a single chunk at the end of its render (see
AudioProxy.Peaks.Render), so nothing resets the deadline while it works and
the budget below is a total rather than an idle one — and the peaks
pipeline runs two subprocesses in sequence, each with a timer of its own. A
peaks render slower than this deadline is ended here, by the request loop,
rather than by the pipeline whose timer was supposed to fire first. It takes
a five-minute peaks render at the default to happen.
The same budget is spent twice, on two different things, and which one ran out
decides the status. A request may wait for a render slot before any render
exists; the coordinator's {:rendering, _} is what says that wait is over,
and it both restarts the clock and switches the answer. Expiring before it is
429 — the queue could not reach this request in time, which is the same thing
the semaphore says up front when the queue is full, with the same
Retry-After. Expiring after it is 504, because there is a render and it has
gone silent. Charging a queue wait to a timeout named after the render was
answering 504 for something that never ran.
Outcomes are events, not log calls
Every way this module can finish a render — done, cancelled by a departing
client, failed, timed out, dead — closes an AudioProxy.Telemetry span
rather than calling Logger. AudioProxy.LogHandler turns those events
into the lines an operator reads, and AudioProxy.Metrics counts the same
ones into renders_total and its duration histogram. The span is threaded
through the receive loop
as a plain value because there are half a dozen exit points and no single
function call to wrap.
The two Logger calls that remain are not render outcomes: a supervisor
that would not start a child at all (no span was ever opened), and a client
disconnect, which is a note about this loop — the render's own
:cancelled stop event is what reports the render side of it.
Summary
Types
@type opts() :: keyword()
Plug options.
:executable— the binary to render with, passed through toAudioProxy.Ffmpeg.Render. Unset meansffmpegfromPATH, which is what the mounted pipeline uses; tests mount a chain of their own with a stand-in, so the HTTP lifecycle can be driven without the real encoder.:probe_executable— the binary the audio-only gate probes with, passed through toAudioProxy.Ffprobe. Unset meansffprobefromPATH. A separate key from:executablebecause the gate and the render are two different binaries with two different stand-ins.