AudioProxy.Plugs.RenderAction (audio_proxy v0.7.0)

Copy Markdown View Source

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 refusal itself is AudioProxy.VideoPolicy's, which defaults to exactly that and is a seam only an embedding release can move. 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), under every verdict.

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

Plug options.

Types

opts()

@type opts() :: keyword()

Plug options.

  • :executable — the binary to render with, passed through to AudioProxy.Ffmpeg.Render. Unset means ffmpeg from PATH, 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 to AudioProxy.Ffprobe. Unset means ffprobe from PATH. A separate key from :executable because the gate and the render are two different binaries with two different stand-ins.