Terminals
Motor OS has no kernel pty, no termios, no line discipline, and no signals. A "terminal" is an ordinary userspace program that provides terminal behavior, ANSI interpretation, interactive input, and the size protocol below, over plain stdio pipes. Nothing in the kernel knows what a terminal is.
The in-tree terminal providers are:
- sys-tty, the serial console. It is a byte pump: the terminal a console program talks to is whatever is at the far end of the serial line, and sys-tty has no opinion about its size.
- russhd, for SSH sessions that requested a pty. Its size comes from the
SSH client. A session without a pty request (
ssh host command) is not a terminal: its stdout and stderr stay separate SSH streams and pass through untouched. - rmux, which emulates a terminal for each pane, sized by the geometry it computes for that pane.
Nesting composes with no special cases: rush inside rmux inside SSH, each hop a
terminal to the next. Programs built on std::io::IsTerminal, C
isatty, or crossterm work unmodified: what they see is a stdio pipe whose far
end behaves like a terminal, and is_terminal() tells them whether it does.
What is_terminal() means
Terminal status is immutable metadata on a descriptor, fixed when the descriptor is
created. A provider marks the pipes it creates for a child by putting
MOTURUS_STDIO_IS_TERMINAL=true in the child's spawn environment; the runtime
consumes the key during spawn and it never becomes process state. Consequently:
- stdin, stdout, and stderr are independent:
program > filehas a terminal stdin and stderr but a non-terminal stdout, as on POSIX systems; - a duplicated descriptor answers the same as its source; regular files, sockets, null streams, and ordinary pipes answer false;
- changing the environment of a running process changes nothing.
Motor reserves descriptor 3 as the terminal stream (moto_rt::FD_TERMINAL).
It is present exactly when a process belongs to a terminal session but its stdin is not
that terminal, for example the last stage of a pipeline, or program < file.
Keys and size reports arrive on it; it is read-only. The spawning runtime creates it
automatically, so a full-screen program at the end of a pipeline still gets its keys.
Background jobs do not get it, because Motor has no way to stop them from stealing
input, and a spawner can suppress it with MOTURUS_STDIO_NO_TERMINAL.
Terminal size
On Linux the kernel stores a per-tty window size and delivers SIGWINCH
when it changes. Motor OS keeps the useful half of that split, discovery happens once at
the component that owns the terminal and applications get a first size and a change
event, but with no signal available the change event travels in the byte stream the
program is already reading.
The carrier is DEC private mode 2048, an emerging convention that foot, Ghostty, iTerm2, and kitty also implement:
| Sequence | Direction | Meaning |
|---|---|---|
CSI ? 2048 h | app to terminal | subscribe; answered at once with a report |
CSI ? 2048 l | app to terminal | unsubscribe |
CSI ? 2048 $ p | app to terminal | do you support this? |
CSI ? 2048 ; 1 $ y / ; 2 $ y | terminal to app | subscribed / not subscribed |
CSI 48 ; rows ; cols ; h_px ; w_px t | terminal to app | the report (rows before columns) |
A report cannot arrive early enough for a program's first frame, so an owner that
already knows the size, rmux for a pane or russhd for a pty session, puts it in
$COLUMNS and $LINES when it spawns the child, and rush re-exports
them before every command. Only the physical console lacks this, because nobody there
knows the size in advance: a full-screen program on the console paints its first frame at
80x24 and repaints when the first report arrives.
Most host terminals do not implement mode 2048, so a client tries three methods in
order and falls back only while the better one goes unanswered: the subscription; the
one-shot CSI 18 t question; and finally the cursor-position probe in the
bottom-right corner. Polling backs off from 250 ms to ten seconds and then thirty, and a
confirmed subscription silences polling entirely. russhd answers the sequences itself and
swallows them, because a sequence passed through would be answered a second time by the
user's own terminal with a different size. crossterm's Motor backend does all of this for
every TUI program, turning a report into Event::Resize, so red, rush, and the
rmux client need no Motor-specific code.
Ctrl+C
Ctrl+C is terminal control, not terminal data. The provider that writes terminal
input into a child's stdin scans for byte 0x03 at that one point; relays
below it carry the event in the pipe and never rescan data, so 0x03 in an
ordinary pipe or file stays an ordinary byte.
- Every process with terminal input starts in the Default state, in which Ctrl+C terminates it with status 130, even if it is spinning, deadlocked, or never reads stdin. All its threads stop; Rust stacks are not unwound.
- A process that wants something else registers one process-lifetime handler through
moto_rt::process::ctrl_c_register_handler()and waits for the event sequence. Applications normally use thectrlccrate'sset_handler, or crossterm'senable_ctrl_c_events(), which turns events into Ctrl+C key events; the two share the one handler, so a program picks one. - While a shell is waiting for a foreground child, Ctrl+C is forwarded to that child: a Default child exits 130, a child with a handler is notified, and the shell itself is neither killed nor called back. Chains compose: SSH to rmux to a pane's rush to the foreground program.
- Bytes up to and including the last
0x03in one write are discarded, matching the input flush of an interrupt; type-ahead after it is kept in order.
rush treats an exit status of 130 as an interrupt: $? stays 130, a pending
trap INT runs once, and the rest of the pipeline, list, or loop is
abandoned. Motor has no process groups, so foreground ownership is the relay route, not
a group broadcast.
Stdio redirection
A child can be given a pipe, nothing, an inherited descriptor, or a real file. The last case has two implementations, and which one a program gets is decided by the shell:
| Direct transfer | Relay | |
|---|---|---|
| what the child gets | a real file descriptor of its own | a pipe |
| who owns the offset | the child, independently | the parent's live file |
| chosen when | a fresh redirect to a regular file on a single command | a file-backed stream is inherited, or shared by several commands |
| file identity visible to the child | yes | no |
The direct route exists because a program may need to know what its stdout is:
ripgrep refuses to search the file it is writing, and with a pipe it cannot tell, so
rg alpha . >> results.txt would find alpha in its own output.
The relay exists because a snapshot of an offset is safe exactly once: sequential
grandchildren inheriting a file must share one live offset or they would overwrite each
other. In rush, cmd > f, cmd > f &, and
cmd 1> f 2>&1 use the direct route; compound commands, functions,
pipelines, and builtins with a redirect use a pipe.
wait() includes the completion of a child's file relays, so a shell cannot
start the next command while the previous one's output is still in flight. When a parent
with live relays exits, bytes a child was told were written are never dropped. Measured
on a 64 MiB payload, the direct route ran at 376 MiB/s and the relay at 524 MiB/s with a
few megabytes of memory each, against 130 MiB/s and 203 MiB for the old approach of
capturing the child's output and writing the file afterwards.
Security properties
is_terminal() guarantees descriptor consistency, nothing more. It does not
prove a human is present, identify the peer, or grant authority; a process creator can
always construct an endpoint that claims terminal behavior, on Motor as on systems with
ptys. Programs may use it to decide whether prompting, colors, or raw-mode editing are
practical; code that authorizes an operation must use an explicit policy.
The full design, including the tests that check each rule, is
docs/tui.md in the repository.