Elixir Escript Daemon — Stdio Encoding Trap
SkillFiles & storageFix intermittent `{:no_translation, :unicode, :latin1}` crashes in Elixir escript daemons that use length-prefixed framed IPC on stdin/stdout. Use when: (1) daemon worker crashes only on some input files, usually ones with non-ASCII bytes (kanji, cyrillic, emoji); (2) error surfaces as `Protocol error` from daemon's error branch or as garbled frame-length bytes seen by the orchestrator/client side; (3) standalone one-shot mode works fine on the same input but multi-request daemon mode fails; (4) `IO.binread(:stdio, N)` returns `{:error, {:no_translation, :unicode, :latin1}}` despite the "bin" prefix suggesting it should be encoding-agnostic. Root cause is the escript default `:standard_io` encoding — it's `:unicode`, and `IO.binread` still routes through the io_server, which translates bytes to codepoints and errors when raw binary frames contain invalid UTF-8 sequences.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Elixir Escript Daemon — Stdio Encoding Trap skill
What this skill tells your AI
The instructions your AI receives, as published by disentinel/grafema in .claude/skills/elixir-escript-daemon-stdio-encoding/SKILL.md and read by ahel’s review.
Problem
Elixir escripts that implement a length-prefixed framed IPC daemon over stdin/stdout (e.g. language analyzers, worker pools, plugin runners driven by an external orchestrator) crash intermittently with:
Protocol error: {:no_translation, :unicode, :latin1}
The crash appears non-deterministic: same daemon, same code, some files trigger it, most don't. Files that trigger it tend to contain multi-byte UTF-8 sequences (kanji, cyrillic, emoji) in comments, atoms, or string literals. Running the daemon in stand-alone single-request mode works fine — only the long-lived multi-request mode fails.
Context / Trigger Conditions
All of these must apply:
- Elixir escript (
mix escript.buildoutput) with a--daemonmode - Length-prefixed frames on stdin/stdout (typical pattern:
<<len::32>> <> payload) - Payloads can contain arbitrary bytes — JSON with UTF-8, raw binaries, etc.
- The daemon uses
IO.binread(:stdio, N)/IO.binwrite(:stdio, data)for framing - External orchestrator feeds requests over a pipe (
Stdio::piped()), not a terminal
Typical symptom chain:
- Orchestrator logs
beam analyzer failed ... Pool request failed - Daemon stderr contains
Protocol error: {:no_translation, :unicode, :latin1} - The daemon's own error branch printed that line after
read_framereturned{:error, {:no_translation, :unicode, :latin1}} IO.binread(:stdio, 4)produced the tuple-error — despite being "binary" read- The specific failing file(s) repeat between runs, but which daemon worker in the pool gets the file varies, giving the illusion of flakiness
Root Cause
Two non-obvious facts collide:
-
Escript initializes
:standard_iowithencoding: :unicode. This is the default group leader configuration; it's not documented as "will bite you on binary IPC." -
IO.binreadandIO.binwritestill route through the io_server. The "bin" prefix means "returns binaries, not lists" — NOT "bypasses encoding." The io_server validates/translates bytes against the device's current encoding. For a:unicodedevice, non-UTF-8 byte sequences raise{:no_translation, :unicode, :latin1}.
So any frame payload that happens to contain a byte sequence that isn't valid UTF-8
(random length headers with high-bit bytes, binary data, specific multi-byte tails of
truncated reads) kills the io_server interaction, which surfaces on the very next
IO.binread as that error tuple.
Secondary contributor: compiler/parser warnings from things like Code.string_to_quoted
go through Logger, whose default handler also writes to :standard_io. Any such
warning from a worker adds extra bytes to the frame stream and desynchronizes the
peer even when the io_server doesn't error.
Solution
At daemon startup, before any stdin read or stdout write:
def main(args) do
# Force stdio to flat byte passthrough. latin1 is 1:1 with bytes — any byte
# value is a valid latin1 codepoint, so IO.binread/binwrite never translate.
:io.setopts(:standard_io, encoding: :latin1)
# stderr is human text — keep it :unicode so inspect()/tuples with multi-byte
# binaries still render correctly.
:io.setopts(:standard_error, encoding: :unicode)
# Route Logger away from stdout so parser/compile warnings can't pollute the
# frame stream. Elixir 1.17+ uses the OTP :logger default handler.
_ =
:logger.update_handler_config(:default, :config, %{type: :standard_error})
case args do
["--daemon"] -> daemon_loop()
_ -> one_shot()
end
end
That's it. No need to rewrite the Protocol module, no raw Port.open({:fd, 0, 1}, ...)
(which causes its own prim_tty: stealing control of fd=0 warning on stdout).
Why Not encoding: :unicode on stdio?
Intuitive first attempt: "set stdio to :unicode so encoding matches actual content."
It makes the Logger/IO.write path for UTF-8 strings work cleanly, and in one-shot mode
(single request per process lifetime) it appears to fix things.
But it makes daemon mode worse: after a successful IO.binwrite of a large UTF-8
JSON payload, the io_server's state is fine for writes but later IO.binread calls
intermittently return the same :no_translation error. Tested on Elixir 1.19 / OTP 27.
:latin1 is the safe choice because every byte is a valid codepoint — no translation
can fail.
Why Not Raw Port via {:fd, 0, 1}?
Port.open({:fd, 0, 1}, [:binary, {:packet, 4}]) seems elegant — it bypasses the
io_server entirely and the VM handles 4-byte length framing for you. It works
functionally, but on escript startup the prim_tty driver already owns fd 0/1 and
prints a warning to stdout when a new port takes over:
21:09:30.549 [error] driver_select(...) by fd (0/1) driver #Port<0.2> stealing control
of fd=0 from resource prim_tty:tty
Those bytes appear on stdout before the first frame, parsed by the orchestrator as
a bogus length header, and the whole conversation desyncs from byte 1. Workarounds
(close user_drv first, dup fds, etc.) add significant complexity. The :io.setopts
fix above is strictly simpler and sufficient.
Verification
Reproducer: a daemon worker that ingests 50+ files in sequence, at least a few containing multi-byte UTF-8 in source:
# harness.py — feed multi-file stress through one daemon
import json, struct, subprocess
files = ["heavy_utf8_1.ex", "ascii_1.ex", "heavy_utf8_2.ex", ...]
proc = subprocess.Popen(["./daemon", "--daemon"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
for f in files:
src = open(f).read()
payload = json.dumps({"file": f, "source": src}).encode("utf-8")
proc.stdin.write(struct.pack(">I", len(payload)) + payload)
proc.stdin.flush()
length = struct.unpack(">I", proc.stdout.read(4))[0]
resp = json.loads(proc.stdout.read(length))
assert resp["status"] == "ok", f"daemon crashed on {f}"
Without the fix: random crashes after 5-30 files. With the fix: 0 crashes across 3+ consecutive full runs.
Example
Real-world case from the grafema repo (packages/beam-analyzer/lib/beam_analyzer.ex,
commit 7d0d9f78): the daemon analyzed 60 Elixir source files from a project that
had kanji atoms (:守り, :内観) and cyrillic comments. Pre-fix: 2–3 files failed
per run, random which ones. Post-fix: 3 consecutive runs, 0 failures, 12166 graph
nodes produced deterministically.
Notes
- The
:standard_iodevice encoding is set at escript VM startup and is stable — one call at the top ofmain/1is enough; no need to re-set per worker or per request. :io.setopts(:standard_error, encoding: :unicode)matters for stderr-bound logs that embedinspect/1results with non-ASCII binaries; without it you can get the same:no_translationerror on error-path writes.- Redirecting Logger doesn't silence it — stderr is inherited by the orchestrator, which typically captures it for diagnostics. That's what you want.
- The
IO.binread/IO.binwritesemantics described here are Elixir-specific; in pure Erlang,file:read/2andfile:write/2on stdio have the same gotcha because they share the io_server. - This fix is strictly safer than the alternatives; there's no scenario where you want escript stdio to do encoding translation on framed binary IPC.
Related Skills
beam-elixir-ast-gotchas— other non-obvious Elixir/Erlang AST processing issues in analyzer toolsnodejs-child-process-stdio-cleanup— analogous trap on the Node.js side when spawning daemons
References
- Elixir
IO.binread/2— does not document the encoding interaction - Erlang
io:setopts/2— theencodingoption controls io_server translation - Erlang
loggerhandler config —type: :standard_erroris the documented way to redirect the default handler
Signals
- GitHub stars
- 36
- Forks
- 3
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
elixir-escript-daemon-stdio-encoding- Source
- github.com/disentinel/grafema