Linx.Tty (Linx v0.1.0)

Copy Markdown View Source

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/tty inside 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/tty somewhere 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_sup in the GL's "$ancestors" chain). Call Linx.Tty.format_error/1 on 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. See attach/2's docstring for the mechanism (:io.setopts(echo: false) + a linked reader sub-process + :io.put_chars/2 for 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.

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.

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

fd()

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

session()

@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

attach(target, session, opts \\ [])

@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/tty directly. 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's Process.group_leader/0 via Erlang's I/O protocol. Works over SSH / :remsh and 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 :echo opt is flipped to false. 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 (default 250). 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.

format_error(other)

@spec format_error(term()) :: binary()

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.

open_controlling_raw()

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

restore_and_close(fd, saved)

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

set_window_size(fd, window_size)

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

version()

@spec version() :: binary()

Returns the linx_tty NIF identifier string — sanity that the native library loaded and its ABI is reachable.

window_size(fd)

@spec window_size(fd()) :: {:ok, Linx.Tty.WindowSize.t()} | {:error, term()}

Returns the current window size of the terminal named by fd (ioctl(TIOCGWINSZ)).