Take the card off disk, not off the pipe

57d611fcb7ac · AtlantisPleb · · parent 9763bf75da3a

Take the card off disk, not off the pipe

Every Open Graph card on the site was corrupt. The endpoint answered 200 with
`image/png`, and the body began `Fontconfig error: No writable cache
directories` -- five times, 240 bytes -- before the PNG signature, so no
decoder would touch it.

The rasterizer collected the image from `System.cmd/3` with
`stderr_to_stdout: true`. librsvg writes the PNG to stdout, fontconfig writes
its complaints to stderr, and merging the two put the complaints in the
picture. The container runs as `nobody`, which has no home and therefore no
writable fontconfig cache, so every render complained and every card carried
it.

librsvg now writes the card to `-o` and the bytes come off disk, where a
warning cannot reach them. The merged output is still collected, because a
binary's chatter is evidence worth logging -- it just never touches a response
body again. The card is checked against the PNG signature before it is served,
so a zero exit with bytes that are not an image fails to the committed card
rather than to a broken one. The child also gets a writable `XDG_CACHE_HOME`,
which removes the complaint at its source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016o8HwTaqLKEWCHTjsjFtrB
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents_web/og/rasterizer.ex
  • added test/openagents_web/og/rasterizer_noise_test.exs

Diff

2 files changed, +116 -5

lib/openagents_web/og/rasterizer.ex modified +57 -5

@@ -19,6 +19,10 @@ defmodule OpenAgentsWeb.OG.Rasterizer do

19 19
20 20
  @timeout_ms 5_000
21 21
22
  # The eight bytes every PNG starts with. The card is checked against them
23
  # before it is served, because the failure this guards against answers 200.
24
  @png_signature <<0x89, "PNG\r\n", 0x1A, "\n">>
25
22 26
  @type error :: :unavailable | :busy | :rasterizer_failed | :timeout | {:exit, term()}
23 27
24 28
  @doc """

@@ -85,15 +89,22 @@ defmodule OpenAgentsWeb.OG.Rasterizer do

85 89
  defp run(bin, svg, opts) do
86 90
    timeout = Keyword.get(opts, :timeout_ms, @timeout_ms)
87 91
    source = temp_path("svg")
92
    target = temp_path("png")
88 93
89 94
    try do
90 95
      File.write!(source, svg)
91 96
92
      task = Task.async(fn -> System.cmd(bin, port_args(source), stderr_to_stdout: true) end)
97
      task =
98
        Task.async(fn ->
99
          System.cmd(bin, port_args(source, target),
100
            stderr_to_stdout: true,
101
            env: environment()
102
          )
103
        end)
93 104
94 105
      case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
95
        {:ok, {png, 0}} when is_binary(png) and byte_size(png) > 0 ->
96
          {:ok, png}
106
        {:ok, {diagnostics, 0}} ->
107
          read_card(target, diagnostics)
97 108
98 109
        {:ok, {_output, status}} ->
99 110
          Logger.warning("og_rasterizer_failed exit=#{status}")

@@ -110,11 +121,52 @@ defmodule OpenAgentsWeb.OG.Rasterizer do

110 121
      error in File.Error -> {:error, {:file, error.reason}}
111 122
    after
112 123
      File.rm(source)
124
      File.rm(target)
125
    end
126
  end
127
128
  # The image comes off disk, never off the pipe. librsvg writes the PNG to
129
  # `-o` and its warnings to stderr, and this call merges stderr into stdout
130
  # deliberately -- so that a warning is logged rather than lost -- which is
131
  # exactly why the bytes cannot come from there. They did once: a container
132
  # whose fontconfig had no writable cache emitted "No writable cache
133
  # directories" five times per render, the collector returned those 240 bytes
134
  # with the PNG behind them, the signature was no longer at byte zero, and
135
  # every card on the site failed to decode while the endpoint answered 200.
136
  defp read_card(target, diagnostics) do
137
    log_diagnostics(diagnostics)
138
139
    case File.read(target) do
140
      {:ok, <<@png_signature::binary, _rest::binary>> = png} ->
141
        {:ok, png}
142
143
      {:ok, _other} ->
144
        # Exit zero and bytes that are not a PNG: the binary is telling the
145
        # truth about its exit and lying about its output. Fail to the
146
        # committed card rather than serve something no decoder accepts.
147
        Logger.warning("og_rasterizer_not_png")
148
        {:error, :rasterizer_failed}
149
150
      {:error, reason} ->
151
        Logger.warning("og_rasterizer_unreadable code=#{reason}")
152
        {:error, :rasterizer_failed}
113 153
    end
114 154
  end
115 155
116
  defp port_args(source),
117
    do: ["-f", "png", "-w", "1200", "-h", "630", "--keep-aspect-ratio", source]
156
  defp log_diagnostics(""), do: :ok
157
158
  defp log_diagnostics(diagnostics) do
159
    # One bounded line. The binary's chatter is operational evidence, not card
160
    # content, and it must never reach a response body again.
161
    Logger.warning("og_rasterizer_diagnostics=#{inspect(String.slice(diagnostics, 0, 200))}")
162
  end
163
164
  # fontconfig wants a writable cache directory and `nobody` has no home, so
165
  # without this every render pays for a miss and says so on stderr.
166
  defp environment, do: [{"XDG_CACHE_HOME", System.tmp_dir!()}]
167
168
  defp port_args(source, target),
169
    do: ["-f", "png", "-w", "1200", "-h", "630", "--keep-aspect-ratio", "-o", target, source]
118 170
119 171
  defp temp_path(extension),
120 172
    do:
test/openagents_web/og/rasterizer_noise_test.exs added +59

@@ -0,0 +1,59 @@

1
defmodule OpenAgentsWeb.OG.RasterizerNoiseTest do
2
  use ExUnit.Case, async: false
3
4
  alias OpenAgentsWeb.OG.Rasterizer
5
6
  @png <<0x89, "PNG\r\n", 0x1A, "\n", "rest-of-a-card">>
7
8
  setup do
9
    previous = Application.get_env(:openagents, :og_rasterizer_bin)
10
    on_exit(fn -> Application.put_env(:openagents, :og_rasterizer_bin, previous) end)
11
    :ok
12
  end
13
14
  defp fake_binary(script) do
15
    path = Path.join(System.tmp_dir!(), "fake-rsvg-#{System.unique_integer([:positive])}")
16
    File.write!(path, script)
17
    File.chmod!(path, 0o755)
18
    on_exit(fn -> File.rm(path) end)
19
    Application.put_env(:openagents, :og_rasterizer_bin, path)
20
    path
21
  end
22
23
  test "a chatty binary cannot pollute the card" do
24
    # The production failure: warnings on stderr, a good PNG at -o, exit 0.
25
    fake_binary("""
26
    #!/bin/sh
27
    echo "Fontconfig error: No writable cache directories" >&2
28
    echo "Fontconfig error: No writable cache directories" >&2
29
    while [ $# -gt 0 ]; do
30
      case "$1" in
31
        -o) shift; out="$1" ;;
32
      esac
33
      shift
34
    done
35
    printf '\\211PNG\\r\\n\\032\\nrest-of-a-card' > "$out"
36
    exit 0
37
    """)
38
39
    assert {:ok, png} = Rasterizer.rasterize("<svg/>")
40
    assert png == @png
41
    assert binary_part(png, 0, 8) == <<0x89, "PNG\r\n", 0x1A, "\n">>
42
  end
43
44
  test "output that is not a PNG fails closed" do
45
    fake_binary("""
46
    #!/bin/sh
47
    while [ $# -gt 0 ]; do
48
      case "$1" in
49
        -o) shift; out="$1" ;;
50
      esac
51
      shift
52
    done
53
    printf 'Fontconfig error: No writable cache directories' > "$out"
54
    exit 0
55
    """)
56
57
    assert {:error, :rasterizer_failed} = Rasterizer.rasterize("<svg/>")
58
  end
59
end

This page updates live while a promote is in flight · changelog