One render: a subprocess spawned from an argv list, its stdout streamed to a consumer as ordered chunks.
AudioProxy.Ffmpeg.Command decides what to run; this module runs it. One
GenServer owns one subprocess, under AudioProxy.Ffmpeg.RenderSupervisor,
and everything downstream — coalescing, chunked delivery, write-back — is a
consumer of the message contract below rather than of ffmpeg itself.
The consumer contract
The consumer is a process, monitored from init/1, and it receives:
{:chunk, render, binary}— stdout bytes, in order{:done, render, %{exit_status: 0}}— the subprocess exited cleanly and every byte it wrote has been delivered{:error, render, reason}— anything else
render is the pid, which is also the handle for ack/2. Exactly one
{:done, _, _} or {:error, _, _} is sent, always after the last chunk, and
the render process stops immediately afterwards. A consumer that also wants
to hear about a crashed render should monitor it.
Argv, never a shell
Port.open({:spawn_executable, _}, args: argv) executes the binary directly.
A source URL containing ;, $(…) or a space is one argv element and stays
data — the injection-safety property AudioProxy.Ffmpeg.Command is written
for only holds because nothing re-parses its output.
Buffering, and what it is not
Ports have no passive read mode: the VM reads the subprocess pipe as fast as
the OS hands bytes over and mails them here, whether or not anyone downstream
is ready. So this process accounts outstanding bytes — forwarded but not
yet acknowledged via ack/2. Above the high-water mark it stops forwarding
and queues internally; ffmpeg then fills the ~64 KB OS pipe and blocks on its
own write. ack/2 drops the count and releases the queue.
That is a bounded buffer, not true backpressure — between the high-water mark
and ffmpeg actually blocking there is a pipe's worth of slack, and a consumer
that never acks still holds whatever this process has queued. It is enough
for preview-sized outputs, which is the decision recorded in CLAUDE.md. The
escalation for full-length transcodes is the named-pipe pattern: ffmpeg
writes to a mkfifo, and Elixir reads it passively with IO.binread in raw
mode, where the OS pipe blocking is the backpressure. Nothing in the
consumer contract above would change.
Lifecycle, and the orphan guarantee
No ffmpeg process outlives its render. Every way this GenServer can stop
— a clean finish, cancel/1, the timeout, a dead consumer, the supervisor
shutting down at VM stop — ends in terminate/2, which closes the port, then
sends SIGTERM, then SIGKILL after a two-second grace. Exits are trapped
so that the shutdown path is one of those ways rather than an exception to
them.
Closing the port is not enough on its own, which is the whole reason the escalation exists: the BEAM does not signal the process on the far side of a closed port. An ffmpeg blocked reading a slow HTTP input may not touch its stdout for minutes, never notice, and sit there holding a slot.
Failure classification
A failed render reports %{class: _, exit_status: _, stderr: _}, where the
class is one of :not_found, :undecodable, :timeout, :cancelled or
:render_failed. ffmpeg exits 1 for almost everything, so the class comes
from matching a bounded tail of its stderr; the HTTP layer maps the class to
a status rather than reading ffmpeg's prose itself.
stderr goes to a per-render file under scratch_dir/0 — merging it into
stdout would splice diagnostics into the audio.
Summary
Types
Why a render failed. See Failure classification in the moduledoc.
What a consumer receives. See the moduledoc.
Functions
Acknowledges bytes the consumer has finished with, releasing that much of
the buffer.
Cancels a running render.
How long cancel/1 may block: the whole worst case — the full grace, the
reap wait, and the kill calls in between — plus margin.
The subprocess' OS pid, or nil if it was reaped before it could be read.
Starts a render.
Types
@type failure() :: %{ class: :not_found | :undecodable | :timeout | :cancelled | :render_failed, exit_status: non_neg_integer() | nil, stderr: binary() }
Why a render failed. See Failure classification in the moduledoc.
@type message() :: {:chunk, t(), binary()} | {:done, t(), %{exit_status: 0}} | {:error, t(), failure()}
What a consumer receives. See the moduledoc.
@type t() :: pid()
A running render. Also the handle passed to ack/2.
Functions
@spec ack(t(), non_neg_integer()) :: :ok
Acknowledges bytes the consumer has finished with, releasing that much of
the buffer.
A consumer that never acks receives at most the high-water mark plus one
chunk, and then nothing further — including {:done, _, _}.
@spec cancel(t()) :: :ok
Cancels a running render.
The consumer is told (%{class: :cancelled}) before the render stops, so a
cancellation is not mistaken for a stream that simply ended. Returns :ok
once the subprocess is gone, which is what makes it usable as a barrier —
including when the caller is the consumer.
@spec cancel_timeout() :: pos_integer()
How long cancel/1 may block: the whole worst case — the full grace, the
reap wait, and the kill calls in between — plus margin.
A call timeout below this would be indistinguishable from the render having
already finished, and would hand back :ok for a subprocess still being
killed. Public because a caller that cancels from its own terminate/2 has
to give itself a shutdown budget larger than this, and a hardcoded number
there would drift the moment the grace changes.
@spec os_pid(t()) :: pos_integer() | nil
The subprocess' OS pid, or nil if it was reaped before it could be read.
Exists for tests that have to prove the process is gone; nothing on the request path needs it.
@spec start_link(keyword()) :: GenServer.on_start()
Starts a render.
Options:
:args— the argument vector, as built byAudioProxy.Ffmpeg.Command.build/3. Required;argv[0]is not included.:consumer— the process receiving the messages above. Defaults to the caller, which is only useful in tests; under the supervisor the caller is the supervisor.:executable— the binary to run. Defaults toffmpegonPATH.
Returns {:error, :ffmpeg_not_found} when no executable was given and none
is on PATH, and {:error, {:executable_not_found, path}} when an explicit
one does not exist — a boot-time misconfiguration surfacing as a start error
rather than as an exception on the request path.