Elixir and the BEAM for AI systems
Exit Code 137 Isn't Always Out of Memory
Exit code 137 means SIGKILL, and memory is only one sender. On one Phoenix project I hit it twice: a compile that ran out of RAM, and a binary that never ran.
I have seen exit code 137 on one project for two unrelated reasons. One was a real out-of-memory kill during a deploy. The other happened on my own laptop, used no memory at all, and I blamed memory for it for months.
I wrote earlier about scheduled posts on this site without a database. This one is about the two times the build killed itself.
What 137 tells you
Exit codes above 128 follow a POSIX convention: 128 plus the number of the signal that ended the process. 137 is 128 + 9, and signal 9 is SIGKILL. The process didn’t exit on its own. Something sent it a kill that it couldn’t catch or clean up after.
That’s all the number says. It tells you which signal arrived. It doesn’t tell you who sent it or why. Most pages that explain 137 are about Docker and Kubernetes, where the usual sender is the kernel’s out-of-memory killer, so the advice jumps straight to “give the container more memory.” That advice is right a lot of the time. It was right for my first case and wrong for my second.
Cause 1: the compile really ran out of memory
The site has no database. Posts live in
priv/content/site_content.json, and the app reads that file
at compile time into module attributes inside
SublimeCoding.Site.Content. At runtime there’s nothing to
query, just terms already sitting in the compiled module.
The catch is how Elixir treats a module attribute inside a function
body. Every reference to @attr inlines the full term into
the code generated for that function. If one function reads the
attribute, the corpus is in the compiled code once. If five functions
read it, it’s in there five times.
Several functions in my content module referenced the big corpus
attributes directly. At 113 posts, mix compile on Fly’s
remote builder ran out of memory partway through a deploy and died with
exit 137. This one really was memory, but the memory was going to copies
of the same corpus in the generated code.
The fix, in commit 5573a0c on July 23, was to reference
each big constant from exactly one function body. Every other use calls
a private accessor:
defp published_constant, do: @published_posts
defp all_tags_constant, do: @all_tags
defp posts_by_tag_constant, do: @posts_by_tag_slugand the functions that used to read the attribute call the accessor instead (wrapped to fit):
defp visible_posts(now) do
published_constant()
|> Enum.reject(&scheduled?(&1, now))
endThe peak memory of a local prod compile went from 4.5GB to 2.45GB. That’s the before and after recorded in the commit, measured on my machine.
There’s a standing rule now, written as a comment above the accessors
in content.ex: a new function that reads a big attribute
calls the accessor, never the attribute. The corpus only grows. It was
113 posts when the builder died, and it’s past 180 now.
Cause 2: a binary that was killed before it ran
The second 137 was mix assets.build on my Apple Silicon
Mac. It had never worked on that machine. It died with exit 137 every
time, and I assumed it was memory because that’s what 137 means in every
article. I treated it as a quirk of the laptop and let Fly build the CSS
on deploy, which worked fine.
The real cause had nothing to do with RAM. The Tailwind standalone binary for macOS arm64 that I downloaded came with an ad-hoc code signature that failed validation. Apple Silicon refuses to run a Mach-O file whose signature doesn’t verify, and the way it refuses is SIGKILL. The process is killed before it uses any memory.
Checking the binary showed it: codesign -v reported an
invalid signature.
My first thought was local corruption, a bad download or a disk
problem. It wasn’t. A fresh download of the same binary from the
project’s GitHub release was byte-identical to the copy in
_build, and it failed the same way. The binary as published
doesn’t pass the check on my machine.
Fly’s builds run on Linux, where there’s no signature check, so only the macOS arm64 binary ever trips it.
The fix
codesign --force --sign - <binary> signs the
binary again, ad hoc, over its actual bytes. After that the signature
verifies and the binary runs. I didn’t want to do that by hand, because
tailwind.install would download it again, bad signature and
all, on the next version bump. So I wired it into the
assets.setup alias with a function in mix.exs,
resign_macos_binaries/1. The core of it checks the
signature and re-signs only when the check fails:
case System.cmd("codesign", ["--verify", bin],
stderr_to_stdout: true) do
{_, 0} -> :ok
{_, _} ->
System.cmd("codesign",
["--force", "--sign", "-", bin],
stderr_to_stdout: true)
endOn Linux and in CI the step doesn’thing, which is also the reason this stayed hidden. Fly deploys always worked because Linux has no signature check. Local builds on the Mac failed every time. Both facts fit the story “the Mac doesn’t have enough memory,” which is why I believed it.
What it cost
Because the local asset build had never succeeded,
priv/static/assets/ on my machine had been stale for
months. CSS was only ever built on Fly. Anything I ran locally that read
the built bundle was reading an old copy. I never saw it, because the
command that should have refreshed it kept failing.
Where I’d look the next time
Both of my cases printed the same number. They had nothing else in common, and the order below is how I’d sort them now.
- Was it an OOM kill? On Linux, look for kernel
messages about the killer.
dmesgorjournalctl -kwill show oom-kill lines if the kernel did it. In a container, check the platform’s own OOM event. I hit three of those running agents in parallel, in Claude Code filled /tmp and froze my Linux box. If you find one, this is memory, and the question is whether the process needs that much or is wasting it. My first case was wasting it. - Did it die instantly? A process that gets killed in
the first second, before it has done any work, was probably not killed
for memory use. It never had time to use any. On macOS, run
codesign -von the binary. That’s how I found mine. - Did something else send
kill -9? A process supervisor, a timeout wrapper, or a CI step with a time limit can all end a job with SIGKILL. The exit code looks identical to an OOM kill. The place to look is the config of whatever launched the command. - Only then, tune memory. Raising the limit’s the cheapest change, and it also hides a problem like the one in cause 1, where the real fix was removing the duplicated data. Check that the sender is the OOM killer before spending time here.
The order is cheapest first. Step 1 is a one-line command. Step 4 changes your build or your infrastructure bill, and it’s the one I’d have reached for first both times.
What I changed
The compile now has headroom, and a rule that stops the next function
from undoing it. The Mac builds assets, and the codesign
step in mix.exs handles the part that breaks the next time
Tailwind’s binary is updated. Next in the same vein: Phoenix’s default
cache-control and the check it breaks.