OTP :ssh_server_channel implementation that serves an ExRatatui.App
over a single SSH channel.
One instance is spawned per SSH channel (i.e. per connected client). It
owns an ExRatatui.Session for that channel and a linked internal
server running the user's app module in :ssh transport mode. Bytes
the ratatui backend writes into the session's in-memory buffer are
shipped back to the client via :ssh_connection.send/3, and bytes
the client types come in as {:data, _, _, _} events that get fed
through the session's ANSI parser and dispatched to the server as
{:ex_ratatui_event, event} messages.
Two entry points
SSH has two ways a client can ask a server to run "something":
- Shell —
ssh hostwith no command; the client expects the server to start the user's default shell and wire it to the channel. - Subsystem —
ssh host -s <name>; the client asks for a named non-shell handler (this is whatnerves_sshuses for itssubsystems:config, whatsftprides on, etc.).
Both are supported here. Shell mode is required for the standalone
daemon so plain ssh just works. Subsystem mode is how nerves_ssh
plugs this into its existing daemon — see subsystem/1 for the exact
shape it expects.
Shell vs subsystem startup
The two modes use different triggers to spin up the server:
Shell mode waits for the client's
pty_req(to size the session) and thenshell_req(to launch). If the client skips the pty, the channel is rejected on the shell request.Subsystem mode can't wait for those messages: OTP
:sshmatches the subsystem name against the daemon's:subsystemsconfig and consumes the{:subsystem, ...}request itself to dispatch us —handle_ssh_msg/2never sees it. Worse, when the client passes-tOTP also consumes thepty_reqbefore the subsystem dispatch fires: OTP hands it to the daemon's default CLI handler (IEx on anerves_sshdevice) and silently orphans that CLI process the moment our subsystem handler takes over the channel. Neither pty_req nor its dimensions reach us through any channel request.To work around that, on
{:ssh_channel_up, ...}we synthesize a default 80x24 session, start the server, and immediately emit a Cursor Position Report roundtrip —ESC[s ESC[9999;9999H ESC[6n ESC[u— which parks the cursor at (9999, 9999) so the client clamps it to the actual pty size, then asks the client to report the position. The response arrives on the next{:data, ...}channel message, is parsed into a%Event.Resize{}by the session's ANSI input parser, and the data handler resizes the session in place + notifies the server. Clients can still send{:window_change, ...}afterwards to track real runtime resizes.
subsystem/1 bakes subsystem: true into the init args so the
channel handler can tell the two flows apart. Shell-mode init (via
ssh_cli:) leaves the flag at its default false.
Client-side caveat: always pass -t in subsystem mode
OpenSSH does not allocate a PTY by default for ssh host -s name
— that's designed for protocols like sftp that don't need one. For
an interactive TUI you MUST force it with -t:
ssh -t nerves.local -s Elixir.MyApp.TUI # ✓ works
ssh nerves.local -s Elixir.MyApp.TUI # ✗ local tty stays in
# cooked mode, keys are
# line-buffered + echoed
# locally, screen redraw
# bleeds into the shell
# prompt on exitWithout -t, render bytes still reach the client and the TUI runs —
it just can't be driven interactively.
Subsystem helper
Returns a {charlist, {module, init_args}} tuple in exactly the shape
OTP :ssh expects for its subsystems: option. Plug it into a
nerves_ssh subsystems list from config/runtime.exs:
# config/runtime.exs
import Config
if Application.spec(:nerves_ssh) do
config :nerves_ssh,
subsystems: [
:ssh_sftpd.subsystem_spec(cwd: ~c"/"),
ExRatatui.SSH.subsystem(MyApp.TUI)
]
endThis must live in runtime.exs, not target.exs. On a fresh
MIX_TARGET=rpi4 mix compile Mix evaluates compile-time configs
before it builds deps for the target, so ExRatatui.SSH isn't on the
code path yet and the subsystem/1 call would crash with module ExRatatui.SSH is not available. runtime.exs runs at device boot
after every beam file is loaded but before the OTP application
controller starts :nerves_ssh, which is exactly the window we need.
The Application.spec(:nerves_ssh) guard keeps host builds (where
:nerves_ssh isn't a dep) silent. See guides/ssh_transport.md for
the full write-up.
Dependency injection for tests
init/1 accepts optional :sender and :starter overrides so unit
tests can substitute fakes for :ssh_connection.send/3 and the
internal server's start function without standing up real
infrastructure. Defaults point at the real OTP + Server functions;
production callers never pass either key.
Summary
Functions
Returns a {charlist_name, {ExRatatui.SSH, init_args}} tuple in the
shape OTP :ssh's :subsystems option (and nerves_ssh's
subsystems: list) expects.
Types
@type t() :: %ExRatatui.SSH{ app_opts: keyword(), channel_id: non_neg_integer() | nil, conn: term() | nil, esc_timer: reference() | nil, mod: module(), rendering: boolean(), replier: (term(), boolean(), :success | :failure, non_neg_integer() -> :ok), sender: (term(), non_neg_integer(), iodata() -> :ok | {:error, term()}), server_pid: pid() | nil, session: ExRatatui.Session.t() | nil, starter: (keyword() -> {:ok, pid()} | {:error, term()}), subsystem_mode: boolean() }
Functions
Returns a {charlist_name, {ExRatatui.SSH, init_args}} tuple in the
shape OTP :ssh's :subsystems option (and nerves_ssh's
subsystems: list) expects.
The charlist name is the full module name (e.g. ~c"Elixir.MyApp.TUI")
so each app module gets its own distinct subsystem. Two apps
configured into the same daemon will not collide.
The init args include subsystem: true so the channel handler knows
it was spawned via OTP's subsystem dispatch (which consumes the
{:subsystem, ...} message internally) and can start the TUI server
as soon as the channel is up, instead of waiting for a shell request
that will never arrive.
Examples
iex> ExRatatui.SSH.subsystem(SomeTUIModule)
{~c"Elixir.SomeTUIModule", {ExRatatui.SSH, [mod: SomeTUIModule, subsystem: true]}}