본문으로 건너뛰기
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

리뷰

아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.

댓글

로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

보내면 바로 공개됩니다. 이메일 주소나 전화번호 같은 개인 정보는 쓰지 마세요. URL은 2개까지입니다.

0 / 1,000자

이 양식은 reCAPTCHA로 보호되며 Google 개인정보처리방침 및 서비스 약관이 적용됩니다.

같은 저장소의 스킬

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/grafema352026년 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/grafema352026년 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/grafema352026년 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/grafema352026년 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/grafema352026년 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/grafema352026년 8월 24일 업데이트

Disentinel의 스킬 모두 보기

이 스킬의 문제 신고