本文へ移動
cccskills
無料GitHub で公開

elixir-escript-daemon-stdio-encoding

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

インストール方法を見る

含まれるファイル(1)

  • SKILL.md8.9 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

Elixir Escript Daemon — Stdio Encoding Trap

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:

  1. Elixir escript (mix escript.build output) with a --daemon mode
  2. Length-prefixed frames on stdin/stdout (typical pattern: <<len::32>> <> payload)
  3. Payloads can contain arbitrary bytes — JSON with UTF-8, raw binaries, etc.
  4. The daemon uses IO.binread(:stdio, N) / IO.binwrite(:stdio, data) for framing
  5. 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_frame returned {: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:

  1. Escript initializes :standard_io with encoding: :unicode. This is the default group leader configuration; it's not documented as "will bite you on binary IPC."

  2. IO.binread and IO.binwrite still 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 :unicode device, 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_io device encoding is set at escript VM startup and is stable — one call at the top of main/1 is enough; no need to re-set per worker or per request.
  • :io.setopts(:standard_error, encoding: :unicode) matters for stderr-bound logs that embed inspect/1 results with non-ASCII binaries; without it you can get the same :no_translation error 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.binwrite semantics described here are Elixir-specific; in pure Erlang, file:read/2 and file:write/2 on 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 tools
  • nodejs-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 — the encoding option controls io_server translation
  • Erlang logger handler config — type: :standard_error is the documented way to redirect the default handler

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

Fix Elixir/Erlang AST processing bugs in Grafema beam-analyzer. Use when: (1) Elixir parser returns MODULE node but 0 functions/calls — body nesting issue, (2) Erlang parser crashes with "cannot convert list to string" on OTP 26+ — location format changed from integer to keyword list, (3) pipe operator |> creates spurious CALL nodes instead of desugared function calls — clause ordering bug, (4) multi-module .ex files return only the first module — missing __block__ handler, (5) installing Erlang/Elixir on macOS with outdated Xcode/Clang.

日本語の概要は準備中です。原文の説明を表示しています。

Disentinel/grafema362026年8月24日 更新

Fires after fixing any non-trivial bug or regression. Asks: could the graph have caught this as a guarantee? Pairs with reflection-in-and-on-action — picks up after "earliest catchable signal" and asks the next question: was that signal expressible in graph? Triggers: (1) after any non-trivial bug fix is verified working, (2) after a regression report (something used to work, broke), (3) during step 6 (knowledge extraction) of the workflow, (4) when reflection-on-action surfaces a "would have been catchable" signal. Outcome is a triage decision (graph-reachable? rule expressible? rule sound?) and either a draft Linear issue + guarantee proposal, or a recorded "graph capability gap" note. Never auto-creates guarantees.

日本語の概要は準備中です。原文の説明を表示しています。

Disentinel/grafema362026年8月24日 更新

Fix Playwright automation failures against code-server (VS Code in browser). Use when: (1) trust dialog blocks all clicks — "monaco-dialog-modal-block intercepts pointer events", (2) button:has-text() finds wrong buttons behind modal dialog, (3) keyboard shortcuts don't work — Meta vs Control inconsistency, (4) VS Code extension activity bar icon not found by aria-label, (5) panels show placeholder text despite extension being "Connected". Covers trust dialog dismissal, keyboard shortcut hybrid mode, extension panel selectors, and database connection verification for Grafema extension.

日本語の概要は準備中です。原文の説明を表示しています。

Disentinel/grafema362026年8月24日 更新

Systematic methodology for achieving 100% backward dataflow reachability in a new language. Create gauntlet fixture, write trace, diagnose gaps, fix analyzer/algorithm, iterate to 100%. Language-agnostic process. Use when: (1) adding a new language to Grafema, (2) auditing dataflow coverage for existing language, (3) user says "/dataflow-gauntlet".

日本語の概要は準備中です。原文の説明を表示しています。

Disentinel/grafema362026年8月24日 更新

Fix docker exec hanging when starting background processes (servers, daemons) inside containers. Use when: (1) docker exec never returns despite using & or nohup, (2) background server started in container causes docker exec to hang indefinitely, (3) env_startup_command in SWE-bench or similar frameworks times out, (4) setsid/disown needed for proper process detachment in Docker. Root cause: docker exec tracks ALL processes in the exec session, not just the top-level PID.

日本語の概要は準備中です。原文の説明を表示しています。

Disentinel/grafema362026年8月24日 更新

Fix Node.js CLI tools crashing inside Docker containers when host-installed node_modules require a newer Node version than the container provides. Use when: (1) "SyntaxError: Invalid regular expression flags" with /v flag in string-width or similar packages, (2) node_modules installed on host with Node 20+ but container has Node 18, (3) `npm install` with file: protocol creates symlinks that break inside Docker, (4) pnpm workspace packages become broken symlinks in containers. Covers version detection, Node binary mounting, and npm install strategies for cross-version compatibility.

日本語の概要は準備中です。原文の説明を表示しています。

Disentinel/grafema362026年8月24日 更新

Disentinel のスキルをすべて見る

このスキルの問題を報告する