Linux terminal / PTY primitives — /dev/tty access, termios(3)
save and restore, tty ioctl(2) (window size), and the byte-pumping
attach/2 that composes with Linx.Process's stdio: :pty to give
the BEAM a docker attach experience.
Why a separate subsystem
Terminals are a coherent kernel-subsystem concept with their own
primitives — line discipline, controlling-terminal rules, the
termios struct, the tty ioctl(2) surface. Linx.Process knows
enough about PTYs to set one up for a workload (stdio: :pty); the
interactive layer that wires the workload's PTY to the caller's
controlling terminal lives here.
What this module is not
Not an interactive-shell library. Not a terminfo / tput layer. Not
a line editor. The point of Linx.Tty is to expose the kernel
primitives so a consumer can build those things on top — or compose
them with Linx.Process in the one way this subsystem builds in:
attach/2.
/dev/tty, not fd 0
The BEAM's stdio is mediated by an Erlang group-leader process; the
underlying fds are not generally usable from Elixir code, and even
if they were, going through them would race the group leader.
Linx.Tty opens /dev/tty directly — the controlling terminal of
the BEAM process, independent of the group leader. That is what C
programs like vim and less do, and it works the same way from
the BEAM whether iex is running in a terminal emulator, over SSH,
inside tmux, or as a Nerves device console.
When the BEAM has no controlling terminal at all (redirected stdio,
some CI environments), opening /dev/tty fails cleanly with
{:error, %Linx.Tty.Error{operation: :open, errno: :enxio}} — a typed
error a caller can pattern-match on, not a crash.
/dev/tty is the BEAM's terminal, not necessarily yours
:controlling targets /dev/tty — the BEAM process's controlling
terminal. That is not always the terminal the caller is typing
into. The distinction matters in three environments:
- SSH iex (e.g.
ssh nerves-foo.local→ iex on a Nerves device). Erlang's SSH daemon (ssh_cli) is a pure I/O-protocol bridge; there is no kernel tty behind the SSH session./dev/ttyinside the BEAM resolves to the BEAM's actual controlling tty (on Nerves: the HDMI / UART console), not the SSH session. :remsh(iex --sname foo --remsh bar@host). The iex shell's group leader is an IO server living in the local node; the remote BEAM has its own/dev/ttysomewhere else.- Headless deployments where the BEAM's controlling tty is a serial port the user can't physically reach.
Two pieces close those gaps:
attach(:controlling, _)refuses with{:error, :no_local_tty}when the caller's group leader is fronted by Erlang's SSH daemon (detected by:ssh_sup/:sshd_supin the GL's"$ancestors"chain). CallLinx.Tty.format_error/1on the atom for the hint.attach(:group_leader, session)pumps through the caller's group leader instead of/dev/tty, working over SSH,:remsh, and locally as a universal alternative to:controlling. Seeattach/2's docstring for the mechanism (:io.setopts(echo: false)+ a linked reader sub-process +:io.put_chars/2for output + polled winsize).
Both modes also refuse {:error, :no_process} when called against
a session whose workload has already exited (or whose GenServer is
gone) — without this the
pump would set itself up waiting for :pty_out events that
can never arrive, and Ctrl-C wouldn't help (ssh_cli
intercepts it and the pump's reaction is to write <<3>> to a
dead session). Linx.Process.info/1 is the cheap stage query
behind the guard.
Save and restore is mandatory
Any operation that mutates the local terminal's state hands the
caller back a Linx.Tty.Saved.t/0 blob with which to restore it
exactly. If the caller forgets to restore and the terminal stays in
raw mode, the user has to type reset(1) blind to recover. The API
shape (open_controlling_raw/0 returning the saved state, paired
with restore_and_close/2) and the attach/2 try/after finalisation
exist so this can't happen accidentally.
Coexisting with iex's tty driver
When attach/2 is called 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 — the user sees roughly
every other keystroke vanish.
attach/2 handles this by bracketing its pump with
:prim_tty.disable_reader/1 / :prim_tty.enable_reader/1, reaching
into user_drv's state via :sys.get_state/1 to find the
prim_tty state record. The competing reader process parks in an
inner receive until attach returns and re-enables it. When the
BEAM isn't running under user_drv (escripts, non-shell apps,
ssh-shell driver variants), the bracket is a no-op.
Runtime SIGWINCH propagation
attach/2 also forwards live terminal resizes (drag the corner of
your emulator while inside the attached shell) to the workload's
PTY. It does this by registering a Linx.Tty.SigwinchHandler
instance on OTP's :erl_signal_server for the lifetime of the
attach; each SIGWINCH becomes a {:linx_tty, :sigwinch} message
in the pump's mailbox, which re-reads TIOCGWINSZ on the local tty
and pushes the new size through Linx.Process.pty_set_winsize/2.
Inside the container, bash / vim / top then see SIGWINCH
through their own (slave-side) tty and redraw at the new size.
Coexists with prim_tty_sighandler (iex's own SIGWINCH consumer):
:gen_event broadcasts to every registered handler.
Summary
Types
An open file descriptor referring to a tty device. Integer — the
caller hands it back to restore_and_close/2, window_size/1, etc.
A Linx.Process session pid (running with stdio: :pty). The
attaching side never touches the workload's master fd directly; it
goes through Linx.Process.pty_write/2 and the :pty_out event
stream.
Functions
Hands the caller's terminal over to session's PTY master and
pumps bytes both ways until the workload exits, then restores
the terminal.
Opens /dev/tty and switches it to raw mode (cfmakeraw(3)), saving
the current termios so it can be restored later.
Restores the saved termios on fd and closes the fd.
Sets the window size of the terminal named by fd
(ioctl(TIOCSWINSZ)).
Returns the linx_tty NIF identifier string — sanity that the native library loaded and its ABI is reachable.
Returns the current window size of the terminal named by fd
(ioctl(TIOCGWINSZ)).
Types
@type fd() :: non_neg_integer()
An open file descriptor referring to a tty device. Integer — the
caller hands it back to restore_and_close/2, window_size/1, etc.
@type session() :: pid()
A Linx.Process session pid (running with stdio: :pty). The
attaching side never touches the workload's master fd directly; it
goes through Linx.Process.pty_write/2 and the :pty_out event
stream.
Functions
@spec attach(:controlling | :group_leader, session(), keyword()) :: {:ok, {:exited, non_neg_integer()} | {:signaled, pos_integer()} | :detached} | {:error, :no_local_tty | :no_process | :gl_eof | term()}
Hands the caller's terminal over to session's PTY master and
pumps bytes both ways until the workload exits, then restores
the terminal.
target selects how the caller's terminal is reached:
:controlling— open/dev/ttydirectly. Works wherever the BEAM's controlling terminal is the user's terminal (local iex on a terminal emulator, Nerves HDMI / UART console). Refuses with{:error, :no_local_tty}when the caller is over SSH (the local-tty guard).:group_leader— pump through the caller'sProcess.group_leader/0via Erlang's I/O protocol. Works over SSH /:remshand anywhere else the user's terminal is an Erlang process rather than a kernel tty fd. Also works on a local terminal; it's the universal mode.
Returns the terminal event from the session — {:ok, {:exited, n}},
{:ok, {:signaled, n}} — {:ok, :detached} if the caller typed the detach
sequence (see below), or {:error, _} for a setup failure or a pre-exec
workload error.
Detaching (leaving the workload running)
opts[:detach_key] is a byte sequence that, when typed, ends the attach
without stopping the workload: the pump returns {:ok, :detached} and
the terminal is restored, but the workload keeps running, ready to be
re-attached. It defaults to <<16, 17>> — Ctrl-P Ctrl-Q, docker's default.
Pass detach_key: nil (or "") to disable it, in which case attach/3
returns only when the workload itself terminates.
The sequence is matched byte-for-byte across reads: a lone first byte (e.g. Ctrl-P) is held one keystroke; if the following byte does not complete the sequence, the held byte is forwarded to the workload ahead of it, so a detach prefix that is also a useful key still reaches the workload when it isn't a detach.
:group_leader mode specifics
Implementation: set :io.setopts(gl, echo: false) so :group
routes input through its :dumb state (byte-oriented, no line
editor); flip the driver's :prim_tty output mode from
:cooked to :raw via :sys.replace_state/2 so workload
output bytes pass through verbatim instead of being rendered
in caret notation (without this, the workload's
backspace-erase echo arrives at the SSH client as ^( ^();
spawn a reader sub-process that loops on
:io.get_chars(:standard_io, ~c"", 1) and forwards bytes to
the pump's mailbox. N = 1 is intentional: :group's
:dumb state runs collect_chars (non-eager), which waits
for exactly N chars before returning; the eager variant
(collect_chars_eager, which returns whatever's available)
is gated by shell = noshell, unreachable with an iex shell
attached. One byte per round-trip is sub-microsecond and
invisible at interactive speeds, including paste.
Both transient state changes (echo and prim_tty output mode)
are restored unconditionally on exit via try/after, even on
a raise inside the pump.
Ctrl-C handling
ssh_cli intercepts byte \x03 (Ctrl-C) from the SSH stream
and turns it into exit(group, interrupt) instead of passing
the byte through. The pump translates that back into a literal
\x03 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), and as a direct
{:EXIT, ^gl, :interrupt} to the pump's mailbox (the race case
where the interrupt arrives between two reader round-trips).
The workload's PTY line discipline then turns the byte into
SIGINT for the foreground process group — the user-visible
"Ctrl-C interrupts the running command" behaviour.
pump output via :io.put_chars(gl, bytes), which sends
{put_chars, :unicode, _}. The unicode encoding is mandatory
here — OTP's ssh_cli has no iorequest clause for :latin1
and silently drops IO.binwrite/2's `{put_chars, :latin1, }via itsunhandled_requestcatch-all, hanging the caller. Window size is seeded from:io.columns/0+:io.rows/0and re-checked on a polling timer (default 250ms) since SSH has no SIGWINCH equivalent. The choice of polling vs an event-driven trace hook on ssh_cli is discussed at the@winsize_poll_ms`
module attribute below.
Side-effects worth knowing about, all transient (restored on return, even on a raise inside the pump):
- The caller's
:echoopt is flipped tofalse. iex's line editing (history, completion,^A/^E, etc.) is bypassed for the duration — bytes go to the workload, not the iex readline. Expected; the workload's own shell does its editing on the other side of the PTY. - Window-size updates lag by up to
:winsize_poll_ms(default250). Fine for shells; barely noticeable even mid-vim.
Caveat: the SSH transport keeps the line-discipline of the
user's local terminal (your local ssh client puts your local
terminal in raw mode by default; if it didn't, no amount of
BEAM-side wrangling would deliver per-keystroke input). All
observed nerves_ssh paths are fine here.
Owner requirement
attach/2 must be called from the process that owns session —
the pid that received {:linx_process, :ready, _} when the session
was spawned. The pump waits for {:linx_process, :pty_out, _}
events in the calling process's mailbox; if they go elsewhere it
blocks forever. The owner defaults to the caller of spawn/1, so
the natural case ("spawn and attach from the same place") works
without thought.
Restore is unconditional
The byte pump runs in the calling process and blocks until the
workload terminates. The caller's terminal is restored
unconditionally via try/after, even on a crash inside the loop,
so a wedged terminal is structurally impossible.
Returns a human-readable description for error atoms Linx.Tty
returns. Currently covers :no_local_tty (the local-tty guard's
refusal); falls back to inspect/1 for any other shape so it can
be safely chained at error sites without losing information.
@spec open_controlling_raw() :: {:ok, fd(), Linx.Tty.Saved.t()} | {:error, term()}
Opens /dev/tty and switches it to raw mode (cfmakeraw(3)), saving
the current termios so it can be restored later.
Returns {:ok, fd, saved} on success — fd for wrapping with
:erlang.open_port({:fd, fd, fd}, [...]), saved for
restore_and_close/2. {:error, %Linx.Tty.Error{}} covers the
failure paths (operation is one of :open, :tcgetattr,
:tcsetattr); the most common case — BEAM without a controlling
terminal — surfaces as
{:error, %Linx.Tty.Error{operation: :open, errno: :enxio}}.
Pair every successful call with restore_and_close/2 (idiomatically
in a try/after) so the user's terminal can never be left stuck in
raw mode.
@spec restore_and_close(fd(), Linx.Tty.Saved.t()) :: :ok | {:error, term()}
Restores the saved termios on fd and closes the fd.
Symmetric finaliser for open_controlling_raw/0. Idempotent against
already-closed fds — calling it twice (e.g. once explicitly, then
again from an outer try/after) is safe.
@spec set_window_size(fd(), Linx.Tty.WindowSize.t()) :: :ok | {:error, term()}
Sets the window size of the terminal named by fd
(ioctl(TIOCSWINSZ)).
The common path for setting the workload's window size goes through
the agent — Linx.Process.pty_set_winsize/2. This
verb is for the rare case of mutating a tty fd held directly by the
caller.
@spec version() :: binary()
Returns the linx_tty NIF identifier string — sanity that the native library loaded and its ABI is reachable.
@spec window_size(fd()) :: {:ok, Linx.Tty.WindowSize.t()} | {:error, term()}
Returns the current window size of the terminal named by fd
(ioctl(TIOCGWINSZ)).