Hands-on examples of Linx.Tty — the terminal/PTY primitives.
These run unprivileged. Most need an iex -S mix session attached to
a real terminal (the BEAM's controlling tty). mix test covers the
error paths; the success paths live here because they mutate your
actual terminal state and are best demonstrated interactively.
NIF identifier
Linx.Tty.version()Returns "linx_tty" — a cheap round-trip that confirms the native
library you built is the one actually loaded.
Reading the terminal's window size
{:ok, fd, saved} = Linx.Tty.open_controlling_raw()
Linx.Tty.window_size(fd)
:ok = Linx.Tty.restore_and_close(fd, saved)open_controlling_raw/0 returns {:ok, fd, %Linx.Tty.Saved{...}};
window_size/1 returns {:ok, %Linx.Tty.WindowSize<132x42>} (the
struct's Inspect renders cols x rows, so 132x42 means "132
columns, 42 rows").
window_size/1 works on any tty fd, not just the controlling one.
On a non-tty fd it returns {:error, {:ioctl, :enotty}}; on an
invalid fd, {:error, {:ioctl, :ebadf}}.
The save / restore contract
open_controlling_raw/0 always pairs with restore_and_close/2 — the
saved blob exists so the user's terminal can be returned to exactly the
state it was in before:
defp with_raw_controlling(fun) do
with {:ok, fd, saved} <- Linx.Tty.open_controlling_raw() do
try do
fun.(fd)
after
Linx.Tty.restore_and_close(fd, saved)
end
end
endtry/after runs the restore on every path — normal return, raised
exception, throw, exit signal — so the terminal can never be left
stuck in raw mode. restore_and_close/2 is idempotent against an
already-closed fd, so wrapping callers that themselves run after
blocks is safe.
When the BEAM has no controlling terminal
open_controlling_raw/0 returns a typed error rather than crashing:
Linx.Tty.open_controlling_raw()Returns {:error, {:open, :enxio}} in some CI runners, or after
setsid detached the BEAM from its tty. Always pattern-match —
the atom is what makes with chains pleasant:
with {:ok, fd, saved} <- Linx.Tty.open_controlling_raw(),
{:ok, ws} <- Linx.Tty.window_size(fd) do
IO.inspect(ws, label: "current terminal")
Linx.Tty.restore_and_close(fd, saved)
endSetting a window size
set_window_size/2 is the rare direct-on-an-fd path. Most callers
won't reach for it — the typical use case is propagating the local
tty's size onto a Linx.Process PTY workload, and that goes through
Linx.Process.pty_set_winsize/2 so the agent performs the ioctl on
the master fd.
The function exists for the case where you do hold a tty fd directly:
{:ok, fd, saved} = Linx.Tty.open_controlling_raw()
Linx.Tty.set_window_size(fd, %Linx.Tty.WindowSize{rows: 40, cols: 100, xpixel: 0, ypixel: 0})
Linx.Tty.window_size(fd)
Linx.Tty.restore_and_close(fd, saved)Setting a tty's size sends SIGWINCH to the foreground process group,
so attached programs see the new size immediately.
Attaching to a workload's PTY
attach/2 is the composition that makes the whole subsystem
worthwhile. Pair it with Linx.Process running a workload under
stdio: :pty and the caller's terminal becomes the workload's
terminal until it exits.
Two modes pick how the caller's terminal is reached:
:controlling—open("/dev/tty", ...)directly. Works wherever the BEAM's controlling tty is the user's terminal (local iex on a terminal emulator, Nerves HDMI / UART console). Has real event-drivenSIGWINCHresize and the snappiest behaviour.:group_leader— pump bytes throughProcess.group_leader/0via Erlang's I/O protocol. Works over SSH,:remsh, and locally as a universal mode. Polls:io.columns/0+:io.rows/0for resize on a ~250 ms cadence (no SIGWINCH equivalent over SSH).
:controlling actively refuses over SSH — see below.
:group_leader works everywhere.
:controlling mode — local terminal
Run from iex -S mix in a terminal emulator on your laptop, or from
iex on a Nerves device's HDMI / UART console.
alias Linx.Process, as: P
alias Linx.Tty
{:ok, c} = P.spawn(argv: ["/bin/sh"], stdio: :pty)
P.proceed(c)
Tty.attach(:controlling, c)Drops your iex into the workload's /bin/sh. Type whatever; ^D
or exit ends the shell; attach restores your terminal and
returns {:ok, {:exited, 0}} (or a :signaled / :error shape
on abnormal termination).
Internals:
open_controlling_raw/0grabs/dev/ttyin raw mode and saves the original termios.- The fd is wrapped as an Erlang port — keystrokes arrive as
{port, {:data, bytes}}messages. - The pump alternately forwards keystrokes to
Linx.Process.pty_write/2and writes{:linx_process, :pty_out, _}events back to the port viaPort.command/2. try/afterrunsrestore_and_close/2unconditionally on the way out — terminal can't be left in raw mode even if the pump raises.
Coexisting with iex's tty driver
When attach(:controlling, _) runs from iex -S mix, the BEAM
already has Erlang's user_drv / prim_tty driver reading
/dev/tty to support type-ahead at the iex prompt. Two readers
on the same kernel tty buffer alternate-steal each other's bytes
— without mitigation you'd lose roughly every other keystroke.
attach/2 handles this internally: grabs the prim_tty state out
of user_drv via :sys.get_state(:user_drv), calls
:prim_tty.disable_reader/1 before the pump, and
:prim_tty.enable_reader/1 in the try/after so iex's reader
resumes cleanly on return. No caller action required.
What :controlling does over SSH / :remsh
/dev/tty resolves to the BEAM process's controlling terminal,
which has nothing to do with how you got into the iex session.
- Local terminal emulator:
/dev/ttyis your terminal. Attach works. - SSH iex on Nerves (
ssh my-pi.local→ iex):/dev/ttyis the BEAM's controlling tty — the HDMI / UART console — not your SSH session. :remsh: the local-iex side's GL points at a remote IO server;/dev/ttyis wherever the remote BEAM was launched.
This closes the silent-attach-to-the-wrong-terminal trap:
attach(:controlling, _) returns {:error, :no_local_tty} in
the SSH (and likely :remsh) case. Linx.Tty.format_error/1
on the atom renders a human-readable hint that names
attach(:group_leader, _) as the alternative.
:group_leader mode — universal
alias Linx.Process, as: P
alias Linx.Tty
{:ok, c} =
P.spawn(
argv: ["/bin/sh"],
namespaces: [:net, :mount, :pid, :uts, :ipc, :user],
stdio: :pty
)
P.proceed(c)
Tty.attach(:group_leader, c)Your iex blocks; the SSH session is the workload's /bin/sh until
you exit.
Observed end-to-end behaviour over SSH (nerves_ssh, rpi5)
What the iex session looks like in practice:
Linx.Tty.attach(:group_leader, c)
$ ls
releases lib erts-16.4
$ sleep 30
^C
$ exit
{:ok, {:exited, 130}}$is busyboxsh's prompt. Characters echo as you type. Backspace erases them on screen.Ctrl-Cduringsleep 30interrupts the running command and returns control to the prompt — attach does not exit.exitends/bin/shand attach returns. Exit code130is128 + SIGINT(2)—shreports the last command's status, and the last command (sleep) was killed by your earlier Ctrl-C. A clean session that ends with a successful command returns{:ok, {:exited, 0}}.
Side effects of :group_leader mode, transient
For the duration of the attach call:
- The caller's
:io.setopts(:echo)is flipped tofalse. This routes:group's input requests through its:dumbstate (byte-oriented; no line editor), and disables iex's own line-editing features (↑/↓ history, tab completion at the iex prompt) until attach returns. Restored unconditionally on the way out — even if the pump raises. - The SSH driver's
:prim_ttyoutput mode is flipped from:cookedto:raw(via:sys.replace_state/2on the driver pid, directly mutating theprim_ttystate'soptionsmap). Without this,\b, ANSI escapes, and other non-printable bytes from the workload get caret-rendered (\b→^(, etc.) byprim_tty's cooked-mode line editor. Restored to:cookedon the way out. trap_exitis set on the iex shell process so the linked reader sub-process's exit doesn't take the pump down with it, and sossh_cli's race-case^Cinterrupt to the shell pid can be caught and translated to a<<3>>byte for the workload. Restored to the prior value on the way out.
Ctrl-C handling
ssh_cli intercepts byte \x03 from the SSH stream and turns it
into exit(group, interrupt) rather than passing the byte through.
The pump translates that back into a literal <<3>> byte to the
workload's PTY in both shapes it can arrive:
- As
{:error, :interrupted}on the reader's pending:io.get_chars— the common case (reader is blocked on a read when^Carrives). - As
{:EXIT, ^gl, :interrupt}to the pump's mailbox — the rare race case where^Carrives in the tiny window between two reader round-trips.
The workload's PTY line discipline then turns <<3>> into SIGINT
for the foreground process group — the "Ctrl-C interrupts the
running command" behaviour users expect.
Window resize: ~250 ms polling
:group_leader mode polls :io.columns/0 and :io.rows/0 on a
250 ms cadence (the @winsize_poll_ms module attribute in
lib/linx/tty.ex) and forwards the new geometry via
Linx.Process.pty_set_winsize/2 when it changes. Dragging your
SSH client's terminal corner produces a clean redraw within a
quarter second — well under the threshold of perceptible lag for
interactive use. CPU cost is ~60 µs/s (~0.006 % of one core on
the rpi5) — invisible in any practical sense.
There is no SSH-side SIGWINCH equivalent surfaced through :io,
so polling is the practical answer. The trade-offs against an
event-driven :erlang.trace/3 hook on ssh_cli are discussed in
the inline comment on @winsize_poll_ms in lib/linx/tty.ex.
:controlling vs :group_leader — when to pick which
| Environment | Pick |
|---|---|
Local terminal (iex -S mix) | :controlling |
| Nerves HDMI / UART console (a keyboard plugged into the device) | :controlling |
| SSH iex on Nerves (the headline use case) | :group_leader |
:remsh to a remote BEAM | :group_leader |
In short: :controlling is the more-direct path that gives event-
driven SIGWINCH and bypasses one OTP-internals dance (no need to
flip prim_tty output mode); :group_leader is the universal
path that works regardless of how the user reached the iex shell.
You can always start with :controlling, watch for
{:error, :no_local_tty}, and fall back to :group_leader:
case Linx.Tty.attach(:controlling, c) do
{:error, :no_local_tty} -> Linx.Tty.attach(:group_leader, c)
other -> other
endThe owner requirement
The pump waits for {:linx_process, :pty_out, _} in the caller's
mailbox. The owner of those events defaults to the process that
called P.spawn/1 (you can override with the :owner option).
Call attach/2 from the session's owner, or the pump will block
forever waiting on events that go to another process.
In iex this is automatic — spawn, proceed, attach are all just
sequential calls from the iex evaluator. In an OTP application you
typically structure the calling process so it owns the session for
the duration of the attach.
Detaching — leaving the workload running
By default, typing Ctrl-P Ctrl-Q (docker's detach sequence) ends an
attach without stopping the workload: attach/3 returns
{:ok, :detached}, your terminal is restored, and the workload keeps
running, ready to be re-attached.
case Linx.Tty.attach(:group_leader, c) do
{:ok, :detached} -> :still_running # Ctrl-P Ctrl-Q was typed
{:ok, {:exited, n}} -> {:exited, n} # the workload itself ended
{:ok, {:signaled, n}} -> {:signaled, n}
{:error, reason} -> {:error, reason}
endChange or disable the sequence per attach:
Linx.Tty.attach(:group_leader, c, detach_key: <<1, 4>>) # Ctrl-A Ctrl-D
Linx.Tty.attach(:group_leader, c, detach_key: nil) # no detach; return only on exitThe sequence is matched across reads; a lone first byte is held one keystroke and flushed ahead of the next if the match doesn't complete — so a detach prefix that is also a useful key still reaches the workload when you don't follow it with the second byte.
Attaching to a session another process owns
attach/3 must run in the session's owner (above). When a supervisor
owns the session — running the workload as a long-lived service — hand
the event stream over for the duration of the attach with
Linx.Process.set_owner/2, then hand it back:
# `runtime` owns `session`; attach from this process for a while.
:ok = Linx.Process.set_owner(session, self())
result =
try do
Linx.Tty.attach(:group_leader, session)
after
Linx.Process.set_owner(session, runtime)
endIf the workload exits while detached, the supervisor won't have seen
the :exited event — so on reclaiming ownership it re-derives the
workload's state from Linx.Process.info/1 and acts on it. The
lifecycle decision stays the supervisor's, level-triggered. See
Linx.Process.set_owner/2 and docs/process/process-examples.md.
Composing with Linx.Process namespaces
Putting it all together — the headline docker attach /
kubectl exec -it workflow on Nerves over SSH:
{:ok, c} =
Linx.Process.spawn(
argv: ["/bin/sh"],
namespaces: [:net, :mount, :pid, :uts, :ipc, :user],
stdio: :pty
)
# Optional host-side setup before proceed/1:
# move a netlink interface into the new netns, write cgroup
# state, etc., while the child waits at the checkpoint.
Linx.Process.proceed(c)
Linx.Tty.attach(:group_leader, c)
# -> your iex prompt becomes the container's sh until you exitThat's docker attach / kubectl exec -it, end-to-end inside the
BEAM, from a few hundred lines of clean Elixir and a few thin NIFs.
Window size: initial seed
attach/2 seeds the workload's PTY window size from the caller's
terminal at entry, so a fresh sh inside the container sees the
right $LINES/$COLUMNS from the moment it starts — vi and
less open at the correct size, prompts wrap correctly.
:controllingreadsTIOCGWINSZon the local tty fd directly.:group_leadercalls:io.columns/0and:io.rows/0against the GL.
You can also set the workload's size manually at any point —
before proceed/1 for "start the workload at this size", or
post-running to push an update:
alias Linx.Process, as: P
{:ok, c} = P.spawn(argv: ["/bin/sh"], stdio: :pty)
P.pty_set_winsize(c, %{rows: 50, cols: 200, xpixel: 0, ypixel: 0})
P.proceed(c)
# sh starts thinking the terminal is 200x50.Live resize with :controlling mode (SIGWINCH)
While attach(:controlling, _) is running, dragging the corner of
your terminal emulator sends SIGWINCH to the BEAM. attach/2
registers a Linx.Tty.SigwinchHandler on OTP's :erl_signal_server
for the lifetime of the call, so each resize becomes a
{:linx_tty, :sigwinch} message in the pump's mailbox — the pump
re-reads TIOCGWINSZ on the local tty and forwards the new size
through Linx.Process.pty_set_winsize/2. Inside the container,
sh / vi / top then see their own (slave-side) SIGWINCH and
redraw at the new size.
The trick — and the reason this required OTP 28 — is that OTP 26
hadn't yet added :sigwinch to :os.set_signal/2. prim_tty got
SIGWINCH support partway through the OTP 27/28 series; we now ride
on the same plumbing iex itself uses for its line-editor geometry
refresh. No NIF needed.
:gen_event broadcasts each signal to every registered handler.
prim_tty_sighandler (iex's handler, which refreshes its line
editor's idea of width) stays armed throughout — we register
alongside it, not in place of it. Handler IDs ({Module, ref})
keep multiple concurrent attaches independent: each one removes
only its own handler on teardown.