AudioProxy.Ffmpeg.Render (audio_proxy v0.7.0)

Copy Markdown View Source

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.

t()

A running render. Also the handle passed to ack/2.

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

failure()

@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.

message()

@type message() ::
  {:chunk, t(), binary()}
  | {:done, t(), %{exit_status: 0}}
  | {:error, t(), failure()}

What a consumer receives. See the moduledoc.

t()

@type t() :: pid()

A running render. Also the handle passed to ack/2.

Functions

ack(render, bytes)

@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, _, _}.

cancel(render)

@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.

cancel_timeout()

@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.

os_pid(render)

@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.

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

Starts a render.

Options:

  • :args — the argument vector, as built by AudioProxy.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 to ffmpeg on PATH.

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.