# `Linx.Tty`
[🔗](https://github.com/oshlabs/linx/blob/v0.2.0/lib/linx/tty.ex#L1)

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 `t: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.

# `fd`

```elixir
@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`

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

# `attach`

```elixir
@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 io_request clause for `:latin1`
and silently drops `IO.binwrite/2`'s `{put_chars, :latin1, _}`
via its `unhandled_request` catch-all, hanging the caller.
Window size is seeded from `:io.columns/0` + `:io.rows/0` and
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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@spec version() :: binary()
```

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

# `window_size`

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

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
