lib/openagents_web/og/rasterizer.ex

main at 58e6347eeb72 · 5 KB

defmodule OpenAgentsWeb.OG.Rasterizer do
  @moduledoc """
  SVG to PNG, the one impure step in the card pipeline.

  The production path shells out to `rsvg-convert` (librsvg) with a fixed
  width/height and no user-influenced flags: the SVG itself is the only input,
  and every dynamic string inside it was escaped and clamped by
  `OpenAgentsWeb.OG` long before it got here. Fonts come from the release
  image's fontconfig; when the binary is absent the module reports
  `:unavailable` and callers serve the committed fallback card instead of
  erroring.

  Work is bounded twice: a concurrency limiter (`OpenAgentsWeb.OG.Limiter`)
  caps simultaneous ports, and each run has a hard timeout. Tests can replace
  the whole behavior with `:og_rasterizer_mfa`, an MFA applied to the SVG.
  """

  require Logger

  @timeout_ms 5_000

  # The eight bytes every PNG starts with. The card is checked against them
  # before it is served, because the failure this guards against answers 200.
  @png_signature <<0x89, "PNG\r\n", 0x1A, "\n">>

  @type error :: :unavailable | :busy | :rasterizer_failed | :timeout | {:exit, term()}

  @doc """
  Renders one card's SVG to PNG bytes.

  Test seam: set `Application.put_env(:openagents, :og_rasterizer_mfa,
  {Mod, :fun, []})` and it receives the SVG instead of the port pipeline.
  """
  @spec rasterize(String.t(), keyword()) :: {:ok, binary()} | {:error, error()}
  def rasterize(svg, opts \\ []) do
    case Application.get_env(:openagents, :og_rasterizer_mfa) do
      {mod, fun, args} when is_atom(mod) and is_atom(fun) and is_list(args) ->
        apply(mod, fun, [svg | args])

      _mfa_absent ->
        port_rasterize(svg, opts)
    end
  end

  @doc "Whether the configured rasterizer binary exists on this node."
  def available? do
    case System.find_executable(binary_name()) do
      nil -> false
      _path -> true
    end
  end

  defp port_rasterize(svg, opts) do
    bin = binary_name()

    unless executable?(bin) do
      {:error, :unavailable}
    else
      case OpenAgentsWeb.OG.Limiter.acquire() do
        :ok ->
          try do
            run(bin, svg, opts)
          after
            OpenAgentsWeb.OG.Limiter.release()
          end

        :busy ->
          {:error, :busy}
      end
    end
  end

  # find_executable walks PATH per call; cache the verdict per binary name so
  # a burst of cold card requests does not turn into a burst of scans.
  defp executable?(bin) do
    key = {__MODULE__, :executable, bin}

    case :persistent_term.get(key, nil) do
      nil ->
        found = System.find_executable(bin) != nil
        :persistent_term.put(key, found)
        found

      verdict ->
        verdict
    end
  end

  defp run(bin, svg, opts) do
    timeout = Keyword.get(opts, :timeout_ms, @timeout_ms)
    source = temp_path("svg")
    target = temp_path("png")

    try do
      File.write!(source, svg)

      task =
        Task.async(fn ->
          System.cmd(bin, port_args(source, target),
            stderr_to_stdout: true,
            env: environment()
          )
        end)

      case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
        {:ok, {diagnostics, 0}} ->
          read_card(target, diagnostics)

        {:ok, {_output, status}} ->
          Logger.warning("og_rasterizer_failed exit=#{status}")
          {:error, :rasterizer_failed}

        nil ->
          Logger.warning("og_rasterizer_timeout")
          {:error, :timeout}

        {:exit, reason} ->
          {:error, {:exit, reason}}
      end
    rescue
      error in File.Error -> {:error, {:file, error.reason}}
    after
      File.rm(source)
      File.rm(target)
    end
  end

  # The image comes off disk, never off the pipe. librsvg writes the PNG to
  # `-o` and its warnings to stderr, and this call merges stderr into stdout
  # deliberately -- so that a warning is logged rather than lost -- which is
  # exactly why the bytes cannot come from there. They did once: a container
  # whose fontconfig had no writable cache emitted "No writable cache
  # directories" five times per render, the collector returned those 240 bytes
  # with the PNG behind them, the signature was no longer at byte zero, and
  # every card on the site failed to decode while the endpoint answered 200.
  defp read_card(target, diagnostics) do
    log_diagnostics(diagnostics)

    case File.read(target) do
      {:ok, <<@png_signature::binary, _rest::binary>> = png} ->
        {:ok, png}

      {:ok, _other} ->
        # Exit zero and bytes that are not a PNG: the binary is telling the
        # truth about its exit and lying about its output. Fail to the
        # committed card rather than serve something no decoder accepts.
        Logger.warning("og_rasterizer_not_png")
        {:error, :rasterizer_failed}

      {:error, reason} ->
        Logger.warning("og_rasterizer_unreadable code=#{reason}")
        {:error, :rasterizer_failed}
    end
  end

  defp log_diagnostics(""), do: :ok

  defp log_diagnostics(diagnostics) do
    # One bounded line. The binary's chatter is operational evidence, not card
    # content, and it must never reach a response body again.
    Logger.warning("og_rasterizer_diagnostics=#{inspect(String.slice(diagnostics, 0, 200))}")
  end

  # fontconfig wants a writable cache directory and `nobody` has no home, so
  # without this every render pays for a miss and says so on stderr.
  defp environment, do: [{"XDG_CACHE_HOME", System.tmp_dir!()}]

  defp port_args(source, target),
    do: ["-f", "png", "-w", "1200", "-h", "630", "--keep-aspect-ratio", "-o", target, source]

  defp temp_path(extension),
    do:
      Path.join(
        System.tmp_dir!(),
        "og-" <> Integer.to_string(System.unique_integer([:positive])) <> "." <> extension
      )

  defp binary_name, do: Application.get_env(:openagents, :og_rasterizer_bin, "rsvg-convert")
end