coaxlear
coaxlear
A Crystal application that runs inside a docker container: it opens a unix socket, starts a child process, and proxies that process's stdio both to the socket and to the container's stdout.
Status: the plan is complete (phases 0–7). Transport, supervisor, two-way proxying, exit code inheritance, signal forwarding, Docker image and specs are implemented and verified. The normative contract lives in
docs/INTERFACE.md. Russian versions of the documents live indocs/ru/and are not normative.
What it does
- runs a process inside the container;
- talks to the outside world through a unix socket;
- collects the subprocess's stdout and passes it to the container's stdout.
Coaxlear holds no business logic: it is transport and supervision.
Quick start
Build the image and start a container with the command you want supervised:
docker build -t coaxlear .
mkdir -p ./run/coaxlear
docker run -d --name app \
-v ./run/coaxlear:/run/coaxlear \
--restart on-failure:3 \
coaxlear -- sh -c 'while true; do sleep 1; done'
The host connects to ./run/coaxlear/coaxlear.sock, exchanges bytes in both directions, and once the process dies reads the exit code from exit_code in that same directory.
A minimal client, in ruby:
require "socket"
socket = UNIXSocket.new("./run/coaxlear/coaxlear.sock")
socket.close_write # let the child see EOF on stdin
STDOUT.print(socket.read) # read the child's output to EOF
# EOF means the child is done; the exit code is now on disk
puts "exit code: #{File.read("./run/coaxlear/exit_code").strip}"
A fuller, working client is script/host_client.cr, and complete clients in all three languages are in docs/CLIENTS.md — it also shows the two rules that matter for aliases below.
Two things worth knowing before you hit them:
- The
--separator is mandatory.docker run image COMMANDexits with code 2 and prints usage; writedocker run image -- COMMAND. - The uid. The container runs as user
10001, and a bind mount does not remap uid. On Linux give the host directory to that uid (chown 10001) or start the container with--user $(id -u). On macOS Docker Desktop there is no problem: the uid maps to the host user.
Mount layout
Give every instance its own directory. The exit code file is derived from the socket's directory, so two instances sharing one directory overwrite each other's exit code even when their socket names differ.
For several instances the host prepares names before starting anything, using symlinks:
run/coaxlear/
c1/coaxlear.sock <- created by coaxlear inside container 1
c1/exit_code <- container 1's exit code
c2/coaxlear.sock <- container 2
c2/exit_code
links/db1 -> ../c1/coaxlear.sock <- alias, created by the host
links/api -> ../c2/coaxlear.sock
ln -s ../c1/coaxlear.sock ./run/links/db1
ln -s ../c2/coaxlear.sock ./run/links/api
docker run -d -v ./run/c1:/run/coaxlear coaxlear --socket /run/coaxlear/coaxlear.sock -- CMD1
docker run -d -v ./run/c2:/run/coaxlear coaxlear --socket /run/coaxlear/coaxlear.sock -- CMD2
A symlink points at a path, not at an object, so it can be created before the container exists and keeps working after the socket is recreated on the next start. Coaxlear knows nothing about aliases.
Two client-side rules follow from this, both learned the hard way:
- Resolve an alias when you connect, not after EOF. By the end of the stream coaxlear has already removed the socket file, and resolving a dangling symlink raises.
- The exit code sits next to the real socket. For
links/db1it isc1/exit_code, notlinks/exit_code.
Full rules are in docs/INTERFACE.md §7.
Restart policy
Coaxlear does not restart the child process. The container lives exactly as long as the useful work, so when the child dies the container exits with the child's code — and restarting is the host's decision:
docker run -d --restart on-failure:3 ...
Use the attempt limit. A process that fails immediately will otherwise be restarted forever, and the loop is hard to notice from the host.
A container restart is not a process restart in place: everything the process held is gone. Restarting in place was not chosen for v1.
Troubleshooting
Coaxlear's diagnostics go to the container's stderr, so docker logs app shows them. They are in Russian, exactly as the source prints them:
coaxlear: слушаю /run/coaxlear/coaxlear.sock listening on the socket
coaxlear: запущен sh -c while... (pid 7) child started
coaxlear: клиент подключился a host connected
coaxlear: ввод закрыт, ребёнок получил EOF в stdin input closed, child saw EOF
coaxlear: stdin ребёнка недоступен (...), ввод прекращён child's stdin is gone
coaxlear: адресат вывода недоступен (...), отключён output destination dropped
coaxlear: дочерний процесс завершился, код=7 child finished, with its code
The last two are normal events, not errors: the first means the child stopped reading its stdin (usually it exited), the second that a host disconnected and was dropped from the fan-out.
Exit codes:
| Code | Meaning |
|---|---|
0, 2, 42, … |
the child's own code |
128+n |
the child was killed by signal n (143 = SIGTERM) |
1 |
startup failed: socket directory missing, address busy, non-socket on the path |
2 |
usage error: no command after --, unknown flag |
Other levers: --socket PATH overrides the socket path, and COAXLEAR_SOCKET does the same from the environment (the flag wins).
To attach by hand and see what the container sees:
nc -U ./run/coaxlear/coaxlear.sock
macOS: a unix socket does not survive a bind mount on Docker Desktop. The file shows up on the host but connecting fails with ENOENT or ECONNREFUSED, while a sibling container on the same volume connects fine. Verify the channel with two containers there; on Linux — the target platform — it works as designed. Analysis in docs/DESIGN.md, "Unix socket and bind mount".
Building
The image:
docker build -t coaxlear .
The binary alone, built in docker and extracted for a fast edit-test loop:
docker build --target builder -t coaxlear:builder .
docker create --name tmp coaxlear:builder # create without starting
docker cp tmp:/src/bin/coaxlear build/ # extract the binary
docker rm tmp
The coaxlear:deps image is the runtime environment (libraries, user, socket directory) without a binary, which is what lets the test container take the binary from a volume:
docker run -d -v ./build:/build:ro -v ./run:/run/coaxlear \
coaxlear:deps /build/coaxlear -- COMMAND
Tests
crystal spec # 45 examples, ~1.3 s
crystal spec spec/config_spec.cr:5 # one example by file:line
crystal spec -e "подставляет путь" # by full name
crystal tool format --check src spec # formatting check
shards build # build the binary into bin/
End-to-end, against real containers:
zsh script/e2e_docker.sh # 21 checks: container, socket, listener, exit code
zsh script/e2e_named.sh # 18 checks: two instances behind named aliases
e2e_docker.sh builds the binary in docker, puts it in build/, starts a container running ls -lah /, and reads the stream to EOF plus the exit code. e2e_named.sh runs two instances through the db1 and api aliases and checks that streams do not cross and that an alias survives a restart.
On macOS both scripts move the listener into a sibling container on the same volume; on Linux they run it natively, which is the "host connected" case. Signalling into the container is deliberately not covered.
Requires crystal >= 1.20.3 (verified on 1.20.3, Shards 0.20.0).
Documentation
| File | About |
|---|---|
docs/INTERFACE.md |
The normative contract. Paths, data directions, EOF semantics, exit codes, client requirements. Written for the host client |
docs/CLIENTS.md |
Example clients in ruby, crystal and elixir, each covering every §5 requirement, plus the snags of each language |
docs/DESIGN.md |
Decisions with rationale, the phase plan, dissected Crystal 1.20.3 behaviour |
docs/HISTORY.md |
Chronological journal: what was decided, what was built, why |
docs/ru/ |
Russian translations, not normative |
AGENTS.md |
Repository specifics for agents and AI tooling |
docs/AGENTS.ru.md |
Russian translation of the above, not normative |
License
MIT — see LICENSE.
coaxlear
- 0
- 0
- 0
- 0
- 0
- about 5 hours ago
- October 8, 2026
MIT License
Thu, 08 Oct 2026 23:18:47 GMT