<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[MistLab Engineering]]></title><description><![CDATA[Engineering notes from MistLab on building an SSH terminal: terminal emulation, SSH internals, and shipping cross-platform Rust.]]></description><link>https://mistlab.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 29 Oct 2026 11:38:21 GMT</lastBuildDate><atom:link href="https://mistlab.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[What Happens When You Paste into a Terminal: Bracketed Paste, the 4095-Byte Line, and UTF-8 Boundaries]]></title><description><![CDATA[How bracketed paste (mode 2004) works, why terminals must filter its markers, the Linux 4095-byte line limit, and UTF-8-safe chunking.]]></description><link>https://mistlab.hashnode.dev/what-happens-when-you-paste-into-a-terminal-bracketed-paste-the-4095-byte-line-and-utf-8-boundaries</link><guid isPermaLink="true">https://mistlab.hashnode.dev/what-happens-when-you-paste-into-a-terminal-bracketed-paste-the-4095-byte-line-and-utf-8-boundaries</guid><category><![CDATA[Rust]]></category><category><![CDATA[ssh]]></category><category><![CDATA[Linux]]></category><category><![CDATA[terminal]]></category><dc:creator><![CDATA[c-wind]]></dc:creator><pubDate>Wed, 28 Oct 2026 14:37:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad86560ac05f4550526ed30/35533f1f-acf8-4edc-8aea-b9257c066c45.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>Originally published on <a href="https://mistlab.dev/stories/ssh-terminal-03-paste.html">MistLab</a>. Part 3 of <a href="https://mistlab.dev/stories/ssh-terminal.html"><em>Building an SSH Terminal</em></a>.</p>
</blockquote>
<p><em>Part 3 of "Building an SSH Terminal". Code excerpts are from MistTerm v1.2.26, an open-source (AGPL) SSH terminal client by MistLab; they are trimmed</em> </p>
<p>TL;DR</p>
<p><strong>Bracketed paste</strong> is an xterm mode (DEC private mode 2004). Once a program enables it, the terminal wraps each paste in <code>ESC[200~</code> … <code>ESC[201~</code>, so pasted newlines are inserted as text instead of acting as Enter. Readline turns it on by default from version 8.1. Separately, Linux's <code>n_tty</code> caps a canonical-mode line at 4095 bytes plus the newline. Large pastes should be sent in chunks that never split a UTF-8 character. </p>
<p>To a terminal, pasting is typing very fast. The clipboard text is written to the PTY exactly as if the user had typed it. Nothing in the byte stream separates "typed" from "pasted". That causes three separate problems. The shell cannot tell a pasted newline from a pressed Enter. The kernel limits how long one line can be. And splitting a large paste into chunks can cut a multi-byte character in half. </p>
<h2>Problem 1: a pasted newline is an Enter. How does bracketed paste work?</h2>
<p>Paste three lines into bash without any protocol and bash runs each line as soon as its newline arrives. That is inconvenient for a multi-line snippet. It is dangerous for text copied from a web page, which can contain content you never saw rendered, a newline included. </p>
<p>The fix is <strong>bracketed paste</strong>, an xterm extension documented in ctlseqs. A program that wants it enables DEC private mode 2004 (<code>CSI ? 2004 h</code>). From then on, the terminal wraps each paste: </p>
<pre><code>ESC [ 2 0 0 ~   &lt;pasted text&gt;   ESC [ 2 0 1 ~
</code></pre>
<p><img src="https://mistlab.dev/assets/images/ssh-terminal/f03-bracketed-paste.png" alt="Bracketed paste: the program asks for mode 2004, the terminal wraps the paste" /></p>
<p><em>Bracketed paste: the program asks for mode 2004, the terminal wraps the paste</em> </p>
<p>A program that sees the start marker treats everything up to the end marker as literal text. Readline inserts it into the line buffer without executing it, zsh's line editor (since zsh 5.1) does the same, and Vim, documented under <code>:help xterm-bracketed-paste</code>, inserts it without auto-indent. </p>
<p>In GNU Readline, the library behind bash's prompt, bracketed paste became <strong>on by default in readline 8.1</strong>. The NEWS entry reads "Bracketed paste mode is enabled by default", and the source shows the switch: </p>
<pre><code class="language-c">/* readline-8.1, rlprivate.h */
#define BRACKETED_PASTE_DEFAULT 1
/* readline-8.1, readline.c */
int _rl_enable_bracketed_paste = BRACKETED_PASTE_DEFAULT;
</code></pre>
<p>In readline 8.0 the same variable is initialized to <code>0</code>. Bash 5.1 ships readline 8.1, so on current distributions an interactive bash asks for mode 2004 unless the user has set <code>enable-bracketed-paste off</code> in <code>.inputrc</code>. Readline also leaves the mode off for terminals it considers unsuitable (see <code>terminal.c</code>), such as <code>TERM=dumb</code>. </p>
<p>The terminal's side of the contract is small but has to be exact. MistTerm checks the mode bit the remote program set (Part 1), normalizes line endings, and wraps the payload: </p>
<pre><code class="language-rust">// src/ui/terminal_paste.rs
/// Normalize newlines to `\n`; when bracketed paste is on, wrap in
/// `ESC[200~ … ESC[201~` and strip forged markers from the body
/// (so pasted content cannot "end the paste" early and be run as commands).
pub fn build_paste_payload(text: &amp;str, bracketed: bool) -&gt; Vec&lt;u8&gt; {
    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
    if !bracketed {
        return normalized.into_bytes();
    }
    let body = normalized.replace("\x1b[200~", "").replace("\x1b[201~", "");
    let mut out = Vec::with_capacity(body.len() + BP_START.len() + BP_END.len());
    out.extend_from_slice(BP_START);
    out.extend_from_slice(body.as_bytes());
    out.extend_from_slice(BP_END);
    out
}
</code></pre>
<p>The marker stripping is the security-relevant line. If clipboard text contains a literal <code>ESC[201~</code>, a naive terminal forwards it, the program believes the paste ended, and whatever follows is interpreted as typed input, newline and all. The unit test pins this behavior: </p>
<pre><code class="language-rust">let p = build_paste_payload("ls\x1b[201~rm -rf x\n", true);
assert_eq!(p, b"\x1b[200~lsrm -rf x\n\x1b[201~");
</code></pre>
<p>Bracketed paste only helps if the receiving program enabled it. In a program that did not (a plain <code>cat</code>, a script reading with <code>read</code>, a REPL without support), a pasted newline is still an Enter. That is a property of the program, not something a terminal can change. </p>
<h2>Problem 2: why are long pasted lines cut at 4095 bytes?</h2>
<p>Programs that leave the TTY in <strong>canonical mode</strong> (POSIX termios <code>ICANON</code>) do not edit lines themselves. <code>cat</code>, the shell builtin <code>read</code> and Python's <code>input()</code> when stdin is not readline-enabled all fall in this group. The kernel's line discipline buffers input until a newline and implements Backspace, Ctrl-U and similar editing. That buffer is finite. </p>
<p>On Linux the buffer is defined in <code>drivers/tty/n_tty.c</code>: </p>
<pre><code class="language-c">#define N_TTY_BUF_SIZE 4096
</code></pre>
<p><code>n_tty_receive_buf_common</code> handles the full-buffer case. In canonical mode, when the buffer is full and contains no complete line, the kernel keeps one slot free by re-using the last position. That lets a terminating newline still be accepted, while further characters are discarded. The effective result is that <strong>one canonical-mode line holds at most 4095 bytes plus its newline</strong>. Anything beyond is silently lost, and the reading program never knows. </p>
<p><img src="https://mistlab.dev/assets/images/ssh-terminal/f03-canon.png" alt="Canonical-mode line buffer on Linux" /></p>
<p><em>Canonical-mode line buffer on Linux</em> </p>
<p>Two details are easy to get wrong: </p>
<ul>
<li>The <code>MAX_CANON</code> constant in Linux's <code>&lt;linux/limits.h&gt;</code> is 255. It is not the limit that <code>n_tty</code> enforces. POSIX only requires <code>MAX_CANON</code> to be at least 255. </li>
<li>The number is Linux-specific. macOS and the BSDs have a different TTY implementation, and macOS's <code>&lt;sys/syslimits.h&gt;</code> declares <code>MAX_CANON</code> as 1024. We have not measured macOS's actual behavior, so this series makes no claim about it beyond the header value.</li>
</ul>
<p>A terminal cannot work around this. The limit is in the server's kernel, below sshd. What a terminal can do is warn. SSH gives the client no way to query the remote termios state, so MistTerm uses the best signal it has. If the remote program has <em>not</em> enabled bracketed paste and some line in the clipboard is longer than 4095 bytes, it shows a warning before sending: </p>
<pre><code class="language-rust">let bracketed = self.terminal.is_bracketed_paste();
// In line mode (cat/read, …) a single line &gt; 4095 bytes is truncated by the
// tty layer; the terminal can't avoid that, so tell the user.
if !bracketed &amp;&amp; exceeds_canon_line_limit(&amp;normalized) {
    self.pending_toast_error = Some(/* suggest SFTP upload or pasting in vim */);
}
</code></pre>
<p>This is a heuristic. A program in raw mode without bracketed paste would trigger a spurious warning. The text is still sent unchanged either way. Interactive shells and editors that enable bracketed paste run the TTY in non-canonical mode and read the bytes themselves, so they are not subject to this limit. </p>
<h2>Problem 3: chunking without breaking characters</h2>
<p>A paste of several megabytes cannot go to the session thread as one message (Part 2). MistTerm queues it and writes it in pieces of at most <code>PASTE_CHUNK = 4096</code> bytes. Cutting a byte buffer at an arbitrary offset can split a UTF-8 sequence: a CJK character is three bytes and an emoji four. Most receivers reassemble the bytes correctly, but treating each write as a unit of text is common enough that it is worth not producing broken fragments. UTF-8 makes the boundary easy to find, because continuation bytes always match <code>10xxxxxx</code>: </p>
<p><img src="https://mistlab.dev/assets/images/ssh-terminal/f03-utf8-chunk.png" alt="Backing up to a UTF-8 character boundary" /></p>
<p><em>Backing up to a UTF-8 character boundary</em> </p>
<pre><code class="language-rust">/// Next chunk length: at most `max`, never splitting a UTF-8 multi-byte
/// character (unless a single character exceeds `max`).
pub fn next_chunk_len(buf: &amp;[u8], max: usize) -&gt; usize {
    if buf.len() &lt;= max {
        return buf.len();
    }
    let mut n = max;
    // UTF-8 continuation bytes look like 10xxxxxx; back up to a character start.
    while n &gt; 0 &amp;&amp; (buf[n] &amp; 0xC0) == 0x80 {
        n -= 1;
    }
    if n == 0 { max } else { n }
}
</code></pre>
<p>Note that the check is on <code>buf[n]</code>, the first byte of the <em>next</em> chunk. If that byte is a continuation byte, the cut is inside a character, and the loop moves back to that character's lead byte. </p>
<p>The send loop is driven by the UI frame clock, not by a blocking loop. A chunk is removed from the pending buffer only after it was accepted by the session queue. If the queue is full, the same chunk is retried after 20 ms, up to 500 times (about ten seconds without progress). Otherwise the next chunk follows 5 ms later: </p>
<pre><code class="language-rust">const GAP: Duration = Duration::from_millis(5);
const RETRY_GAP: Duration = Duration::from_millis(20);
const MAX_RETRIES: u32 = 500; // give up after ≈10 s without progress
</code></pre>
<p>If it finally gives up, the user is told how many bytes were not sent. A paste is never silently truncated by the terminal itself. </p>
<h2>Summary</h2>
<ul>
<li>Pasted newlines are indistinguishable from Enter unless the program enables bracketed paste (mode 2004). Readline enables it by default from 8.1 onward. </li>
<li>A terminal implementing bracketed paste must strip <code>ESC[200~</code>/<code>ESC[201~</code> from the pasted text, or the clipboard can break out of the paste. </li>
<li>Linux canonical-mode lines are capped at 4095 bytes plus newline by <code>n_tty</code>. <code>MAX_CANON</code> (255) is not that limit, and other kernels differ. </li>
<li>Chunk large writes on UTF-8 boundaries, and only drop a chunk after it has been accepted.</li>
</ul>
<h2>FAQ</h2>
<h3>How does bracketed paste work?</h3>
<p>A program enables DEC private mode 2004 with CSI ? 2004 h. The terminal then wraps each paste in ESC[200~ and ESC[201~, and the program treats everything between them as literal text. </p>
<h3>Is bracketed paste enabled by default in bash?</h3>
<p>Readline enables it by default from 8.1, and bash 5.1 ships readline 8.1. Users can turn it off with enable-bracketed-paste off in .inputrc. </p>
<h3>Why must a terminal strip ESC[201~ from pasted text?</h3>
<p>If the clipboard contains the end marker, the rest of the paste would be treated as typed input, including its newlines. </p>
<h3>Why are pasted lines longer than 4095 bytes cut off on Linux?</h3>
<p>In canonical mode, Linux's n_tty line discipline holds at most 4095 bytes plus the newline. MAX_CANON (255) is not that limit. A terminal cannot work around it, but it can warn.
  <strong>Related in this series</strong></p>
<ul>
<li><a href="https://mistlab.dev/stories/ssh-terminal-02-ssh-channel.html">SSH Flow Control, and Why an SSH Client Must Keep Reading While It Writes</a></li>
<li><a href="https://mistlab.dev/stories/ssh-terminal-04-locale.html">Why Non-ASCII Input Breaks over SSH: Locales, SendEnv and the <code>env</code> Request</a></li>
</ul>
<p>← <a href="https://mistlab.dev/stories/ssh-terminal-02-ssh-channel.html">Part 2</a> · <a href="https://mistlab.dev/stories/ssh-terminal.html">Series index</a> · <a href="https://mistlab.dev/stories/ssh-terminal-04-locale.html">Part 4</a> →</p>
<hr />
<p><em>Originally published at <a href="https://mistlab.dev/stories/ssh-terminal-03-paste.html">https://mistlab.dev/stories/ssh-terminal-03-paste.html</a>. Part of <a href="https://mistlab.dev/stories/ssh-terminal.html">Building an SSH Terminal</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[SSH Flow Control, and Why an SSH Client Must Keep Reading While It Writes]]></title><description><![CDATA[How the SSH channel window works, why a client that only writes can deadlock with the remote program, and how to write with drain off the GUI thread.]]></description><link>https://mistlab.hashnode.dev/ssh-flow-control-and-why-an-ssh-client-must-keep-reading-while-it-writes</link><guid isPermaLink="true">https://mistlab.hashnode.dev/ssh-flow-control-and-why-an-ssh-client-must-keep-reading-while-it-writes</guid><category><![CDATA[ssh]]></category><category><![CDATA[Linux]]></category><category><![CDATA[terminal]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[c-wind]]></dc:creator><pubDate>Mon, 26 Oct 2026 15:40:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad86560ac05f4550526ed30/d8829a44-1a9c-465b-900c-4de1c41a03da.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>Originally published on <a href="https://mistlab.dev/stories/ssh-terminal-02-ssh-channel.html">MistLab</a>. Part 2 of <a href="https://mistlab.dev/stories/ssh-terminal.html"><em>Building an SSH Terminal</em></a>.</p>
</blockquote>
<p><em>Part 2 of "Building an SSH Terminal". Code excerpts are from MistTerm v1.2.26, an open-source (AGPL) SSH terminal client by MistLab; they are trimmed</em></p>
<p>TL;DR</p>
<p>SSH flow control gives each direction of each channel a <strong>window</strong>: the number of bytes the sender may still send before the receiver grants more with <code>SSH_MSG_CHANNEL_WINDOW_ADJUST</code> (RFC 4254 §5.2). When the send window is zero, the client must keep reading the server's output. Otherwise it and the remote program can wait on each other forever. Keep network I/O off the GUI thread, and bound every wait.</p>
<p>Part 1 treated the terminal as a pipe that hands bytes to a parser. Those bytes come from, and keystrokes go to, an SSH channel on a server PTY. Two properties of that path decide how an interactive client is built: the channel is flow-controlled, and the GUI thread must never wait on the network.</p>
<h2>The data path</h2>
<img src="https://mistlab.dev/assets/images/ssh-terminal/f02-dataflow.svg" alt="Data path between the UI, the session thread and the remote shell" style="display:block;margin:0 auto" />

<p><em>Data path between the UI, the session thread and the remote shell</em></p>
<p>When you type <code>l</code>, the UI encodes the key and hands the bytes to the session layer. The SSH library encrypts them into an <code>SSH_MSG_CHANNEL_DATA</code> message (RFC 4254 §5.2). On the server, sshd writes the payload into the master side of a pseudo-terminal. The line discipline in the kernel's tty layer processes it (echo, line editing in canonical mode, signals such as Ctrl+C → SIGINT), and the shell reads from the slave side. Output takes the same path back.</p>
<p>Setting up that path takes three channel requests, all defined in RFC 4254. <code>pty-req</code> (§6.2) carries the terminal type, the size in characters and pixels, and encoded terminal modes. Optional <code>env</code> requests (§6.4) follow; Part 4 covers them. Finally <code>shell</code> (§6.5) starts the user's login shell. Resizing later uses <code>window-change</code> (§6.7). Despite the name, "window" there means the terminal window. It has nothing to do with the flow-control window below.</p>
<h2>How does the SSH channel window work?</h2>
<p>SSH multiplexes many channels over one TCP connection. To stop one fast channel from swamping the connection or the peer's memory, RFC 4254 §5.2 gives each direction of each channel a <em>window</em>: the number of bytes the sender may still transmit. Sending data consumes window. The receiver adds window back with <code>SSH_MSG_CHANNEL_WINDOW_ADJUST</code> after it has consumed data. When the window reaches zero, the sender must stop until an adjust arrives.</p>
<img src="https://mistlab.dev/assets/images/ssh-terminal/f02-window.png" alt="SSH channel window: writes stall at zero until the peer adjusts" style="display:block;margin:0 auto" />

<p><em>SSH channel window: writes stall at zero until the peer adjusts</em></p>
<p>For a terminal, the interesting case is a large write: a big paste, or a file upload streamed through the terminal (Part 5). The client keeps writing until its send window is exhausted and then has to wait. What it does while waiting matters.</p>
<p>The server only re-opens the client's window after its side has consumed input, which means the remote program has to be reading. Interactive programs are often busy <em>writing</em> at the same time: a shell echoing the pasted text, or a progress display. If the client is not reading the server's output, the client never re-grants window in the other direction. The server stops sending, the remote program blocks on its own <code>write()</code>, stops reading its input, and the client's send window stays at zero. Neither side can make progress. A writer has to keep reading.</p>
<h2>Avoiding the write deadlock: write with drain</h2>
<p>MistTerm puts this rule in one function used for all large PTY writes, <code>write_pty_with_drain</code> in <code>src/ssh/manager.rs</code>. The session runs in non-blocking mode (<code>sess.set_blocking(false)</code>), so a write either makes progress or returns immediately. The core of the loop:</p>
<pre><code class="language-rust">const MAX_NO_PROGRESS: usize = 6_000;   // × 150µs sleep ≈ 0.9 s without progress → error
const CHUNK_CEIL: usize = 256 * 1024;

while !rest.is_empty() {
    let len_before = rest.len();
    let win = channel.write_window().remaining as usize;
    if win == 0 {
        // no send window: read output instead, so the peer can make progress
        Self::pump_channel_reads(channel, read_buffer, message_tx, session_id, upload_bypass, None)?;
    } else {
        let chunk = rest.len().min(win).min(CHUNK_CEIL).max(1);
        match channel.write(&amp;rest[..chunk]) {
            Ok(0) =&gt; { /* drain reads */ }
            Ok(n) =&gt; {
                rest = &amp;rest[n..];
                if !rest.is_empty() { /* drain reads between chunks */ }
            }
            Err(e) if Self::is_retryable_write_error(&amp;e) =&gt; { /* drain reads */ }
            Err(e) =&gt; return Err(e),
        }
    }
    if rest.len() &lt; len_before {
        no_progress = 0;
    } else {
        no_progress += 1;
        if no_progress &gt; MAX_NO_PROGRESS { return Err(/* TimedOut */); }
        thread::sleep(Duration::from_micros(150));
    }
}
</code></pre>
<p>(The real code maps a closed channel to <code>ConnectionAborted</code> at each drain point and handles a library return value larger than the request. These are elided here.)</p>
<p>Three design choices are visible:</p>
<ol>
<li><strong>Never write more than the window.</strong> The chunk size is capped by <code>write_window().remaining</code> and by a fixed ceiling, so a write call never has more queued than the protocol allows.</li>
<li><strong>Read whenever writing cannot proceed.</strong> A zero window, a zero-byte write and a would-block error all lead to <code>pump_channel_reads</code>. Output is forwarded to the terminal as it arrives, so the window in the other direction keeps flowing.</li>
<li><strong>Bound the wait.</strong> The no-progress counter only grows when a whole iteration moved no bytes. After about 0.9 s of zero progress the write fails and returns control. Without that, a dead peer would hang the session thread forever.</li>
</ol>
<h2>Keep the network off the UI thread</h2>
<p>The second rule concerns threads. egui is an immediate-mode GUI: the UI thread redraws every frame, and any blocking call on that thread freezes the window. SSH I/O blocks by nature. A write can sit in the drain loop above, and a library call can wait on the socket.</p>
<p>MistTerm gives each interactive session a dedicated OS thread, the <em>shell pump</em>, which alone owns the channel. The UI communicates with it only through bounded queues:</p>
<pre><code class="language-rust">let (pump_tx, pump_rx) = sync_channel::&lt;ShellPumpCommand&gt;(SHELL_PUMP_QUEUE_CAP); // 512
let (interrupt_tx, interrupt_rx) = sync_channel::&lt;Vec&lt;u8&gt;&gt;(8);
let (resize_tx, resize_rx) = sync_channel::&lt;(u32, u32)&gt;(RESIZE_QUEUE_CAP);       // 16
</code></pre>
<p>Each loop iteration of the pump thread first drains the priority queue, then applies pending resizes, then waits up to 8 ms for one command. On timeout it services keepalive and reads output. Adjacent keystroke commands are merged into one write of up to 32 KiB before being sent, so a burst of typing costs one queue entry rather than hundreds.</p>
<p>The UI side never blocks on these queues. Every enqueue uses <code>try_send</code>:</p>
<pre><code class="language-rust">/// Uses `try_send` (non-blocking): if the pump thread is stuck in
/// `write_pty_with_drain` or a long read and the queue is full, the caller
/// (the UI thread) is not blocked; an error is returned so the upper layer
/// can drop or degrade.
fn pump_send(&amp;self, cmd: ShellPumpCommand) -&gt; Result&lt;(), String&gt; {
    self.pump_tx.try_send(cmd).map_err(/* "shell pump queue full (cap=512) …" */)
}
</code></pre>
<p>This is a trade-off. A blocking <code>send</code> never loses input, but it ties UI responsiveness to the network. <code>try_send</code> keeps the window responsive and, under extreme backlog, reports a dropped command instead. With a 512-entry queue and keystroke merging, a full queue means the session is already effectively stalled.</p>
<p>The 8-entry priority queue exists for control bytes that must jump the queue, through <code>send_priority_interrupt</code>. In v1.2.26 its caller is the ZMODEM cancel path (Part 5). It sends eight CAN bytes, eight backspaces and Ctrl+C, so an aborted transfer is cancelled ahead of whatever file data is still queued.</p>
<h2>Summary</h2>
<ul>
<li>An SSH shell is three RFC 4254 requests (pty-req, env, shell) plus window-change for resizes.</li>
<li>Channel data is window-limited in both directions. A client that only writes during a large transfer can deadlock with the remote program. Read whenever the write cannot proceed.</li>
<li>Bound every wait, and keep all network I/O on a dedicated thread. The GUI talks to it through bounded, non-blocking queues.</li>
</ul>
<p>Next: paste. Same big write, different ways it fails — bracketed paste, and the 4095-byte line.</p>
<h2>FAQ</h2>
<h3>What is the SSH channel window?</h3>
<p>A per-channel, per-direction count of bytes the sender may still transmit (RFC 4254 §5.2). Sending consumes it, and the receiver adds to it with SSH_MSG_CHANNEL_WINDOW_ADJUST after consuming data.</p>
<h3>Why can a large paste hang an SSH session?</h3>
<p>If the client only writes, it stops reading the server's output. The server stops sending, the remote program blocks on its own output and stops reading input, and the client's send window stays at zero.</p>
<h3>Is the SSH window-change request related to flow control?</h3>
<p>No. window-change (RFC 4254 §6.7) reports a new terminal size. The flow-control window is unrelated.</p>
<h3>Which channel requests start an interactive SSH shell?</h3>
<p>pty-req, optional env requests, then shell, all defined in RFC 4254. <strong>Related in this series</strong></p>
<ul>
<li><a href="https://mistlab.dev/stories/ssh-terminal-03-paste.html">What Happens When You Paste into a Terminal: Bracketed Paste, the 4095-Byte Line, and UTF-8 Boundaries</a></li>
<li><a href="https://mistlab.dev/stories/ssh-terminal-05-zmodem.html">Finding ZMODEM in a Terminal Byte Stream: How <code>rz</code>/<code>sz</code> Works Inside an SSH Client</a></li>
</ul>
<p>← <a href="https://mistlab.dev/stories/ssh-terminal-01-emulation.html">Part 1</a> · <a href="https://mistlab.dev/stories/ssh-terminal.html">Series index</a> · <a href="https://mistlab.dev/stories/ssh-terminal-03-paste.html">Part 3</a> →</p>
<hr />
<p><em>Originally published at</em> <a href="https://mistlab.dev/stories/ssh-terminal-02-ssh-channel.html"><em>https://mistlab.dev/stories/ssh-terminal-02-ssh-channel.html</em></a><em>. Part of</em> <a href="https://mistlab.dev/stories/ssh-terminal.html"><em>Building an SSH Terminal</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[From Bytes to Glyphs: How a Terminal Emulator Actually Works]]></title><description><![CDATA[How a terminal emulator works: control sequences (ECMA-48, xterm), the VT parser state machine, grid semantics, and rendering CJK wide characters.]]></description><link>https://mistlab.hashnode.dev/from-bytes-to-glyphs-how-a-terminal-emulator-actually-works</link><guid isPermaLink="true">https://mistlab.hashnode.dev/from-bytes-to-glyphs-how-a-terminal-emulator-actually-works</guid><category><![CDATA[Open Source]]></category><category><![CDATA[Rust]]></category><category><![CDATA[terminal]]></category><category><![CDATA[ssh]]></category><category><![CDATA[Linux]]></category><dc:creator><![CDATA[c-wind]]></dc:creator><pubDate>Sun, 25 Oct 2026 10:40:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad86560ac05f4550526ed30/a92a7e8e-6a5b-4c86-be2e-f310f64bc2ae.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>Originally published on</strong> <a href="https://mistlab.dev/stories/ssh-terminal-01-emulation.html"><strong>MistLab</strong></a><strong>.</strong> This is Part 1 of the series <a href="https://mistlab.dev/stories/ssh-terminal.html"><em>Building an SSH Terminal</em></a>.</p>
</blockquote>
<p><em>Part 1 of "Building an SSH Terminal". Code excerpts are from MistTerm v1.2.26, an open-source (AGPL) SSH terminal client by MistLab, written in Rust; excerpts are trimmed.</em></p>
<p>TL;DR</p>
<p>A terminal emulator turns a byte stream from a program into pixels in three stages. A <strong>parser</strong> (a resumable state machine) splits the stream into printable characters and control sequences defined by ECMA-48 and xterm. A <strong>grid</strong> of character cells applies their meaning: cursor, scrolling regions, modes, wide characters, the alternate screen. A <strong>renderer</strong> then draws the grid. MistTerm uses <code>alacritty_terminal</code> for the parser and grid, and draws the grid itself with egui.</p>
<p>Type <code>ls --color</code> and colored file names appear. It feels immediate, but inside the terminal there are three distinct stages between the network and the screen. The remote program writes a stream of bytes with control sequences mixed in. A <strong>parser</strong> turns that stream into actions. Those actions mutate a <strong>grid</strong> of character cells. A <strong>renderer</strong> draws the grid. This article walks through each stage, what the standards say, and how MistTerm wires a mature emulator core into its own GUI.</p>
<h2>How does a program talk to a terminal? Only through a byte stream</h2>
<p>There is no API between a terminal and the program running in it. Programs such as bash, vim and top write bytes to standard output, and that is all. Printable characters are displayed. Everything else is an instruction to the terminal, encoded with conventions that go back to the DEC VT100 and were later standardized, in part, as ECMA-48 (ISO 6429):</p>
<ul>
<li><strong>C0 controls</strong> — single bytes below 0x20: carriage return <code>\r</code>, line feed <code>\n</code>, backspace <code>\b</code>, tab <code>\t</code>, bell <code>BEL</code>.</li>
<li><strong>Escape sequences</strong> — <code>ESC</code> (0x1B) followed by one or more bytes.</li>
<li><strong>CSI sequences</strong> — <code>ESC [</code>, optional parameters, optional intermediates, a final byte in 0x40–0x7E. <code>ESC[31m</code> sets the foreground to red (SGR), <code>ESC[2J</code> erases the display, <code>ESC[10;5H</code> moves the cursor to row 10, column 5.</li>
<li><strong>OSC strings</strong> — <code>ESC ]</code> … terminated by <code>BEL</code> or the string terminator <code>ESC \</code>; used for window titles, hyperlinks and the like.</li>
<li><strong>DEC private modes</strong> — <code>CSI ? Pm h</code> to set and <code>CSI ? Pm l</code> to reset. Mode 1049 switches to the alternate screen (why vim and less seem to "take over" the screen and give it back on exit). Mode 2004 enables bracketed paste, which is the subject of Part 3.</li>
</ul>
<p>ECMA-48 defines the syntax and many of the functions. The de facto reference for what a modern terminal is expected to do is xterm's <em>Control Sequences</em> document (ctlseqs), because most software assumes "xterm-compatible". That assumption is negotiated through <code>$TERM</code>: when MistTerm opens a session it requests a PTY with the terminal type <code>xterm-256color</code>, and programs on the server look that name up in the terminfo database to decide which sequences to emit.</p>
<pre><code class="language-rust">// src/ssh/client.rs
channel
    .request_pty(
        "xterm-256color",
        None,
        Some((cols, rows, px_w, px_h)),
    )
</code></pre>
<h2>Stage 1: how a VT parser state machine works</h2>
<p>A byte stream has no message boundaries. One <code>read()</code> may end with <code>ESC[3</code> and the next begin with <code>1m</code>. The parser therefore has to keep state across calls and consume input one byte at a time.</p>
<p>The widely used model is Paul Williams' DEC-compatible parser (documented at <a href="http://vt100.net">vt100.net</a>), which defines a dozen or so states — ground, escape, CSI entry/param/intermediate/ignore, OSC string, DCS states and so on — and a transition table for every byte value. Transitions emit actions: <em>print</em> a character, <em>execute</em> a C0 control, <em>dispatch</em> a completed escape, CSI or OSC sequence. Some transitions apply "anywhere": <code>ESC</code> always starts a new sequence, and CAN or SUB abort one.</p>
<img src="https://mistlab.dev/assets/images/ssh-terminal/f01-vt-parser.svg" alt="A simplified VT parser state machine" style="display:block;margin:0 auto" />

<p><em>A simplified VT parser state machine</em></p>
<p>In Rust this layer is the <code>vte</code> crate from the Alacritty project, whose parser is based on Williams' state machine. It deliberately stops at turning bytes into callbacks. It does not know what "erase in line" means.</p>
<h2>Stage 2: the grid, where the semantics live</h2>
<p>The meaning of each action is where most of the work is. The visible screen is a <code>rows × columns</code> array of cells. Each cell holds a character plus attributes (foreground, background, bold, underline, inverse, wide-character flags, …). Around it sit the cursor position and its saved copy, the set of active modes, scrolling margins, tab stops, the alternate screen, and scrollback history. The subtle rules are the ones applications notice:</p>
<ul>
<li>After <code>DECSTBM</code> sets a scrolling region, a line feed on its bottom margin scrolls only that region.</li>
<li>The alternate screen has no scrollback.</li>
<li>Writing in the last column leaves the cursor in a "pending wrap" state. The wrap happens only when the <em>next</em> printable character arrives.</li>
<li>A double-width character occupies two cells. Overwriting either half must clear the other.</li>
</ul>
<p>Whether tmux, vim and htop draw correctly depends on these details. Writing a parser takes an afternoon. Getting the grid semantics right takes years of comparing against xterm and test suites such as vttest.</p>
<p>That is why MistTerm does not implement them itself. <code>alacritty_terminal</code> packages the parser, grid and semantics from the Alacritty terminal as a library. MistTerm's adapter (<code>src/terminal/alacritty.rs</code>) feeds it bytes in one line:</p>
<pre><code class="language-rust">pub fn feed(&amp;mut self, data: &amp;[u8]) {
    if !data.is_empty() {
        self.content_epoch = self.content_epoch.wrapping_add(1);
    }
    self.parser.advance(&amp;mut self.term, data);
}
</code></pre>
<p><code>parser</code> is an <code>alacritty_terminal::vte::ansi::Processor</code> and <code>term</code> is a <code>Term</code>. <code>advance</code> consumes a whole chunk and keeps any half-finished sequence for the next call. <code>content_epoch</code> is MistTerm's own counter. The UI compares it between frames and skips re-layout when nothing changed.</p>
<p>Mode queries go straight to <code>Term</code>. For example, this is how the paste code knows whether the remote program has asked for bracketed paste:</p>
<pre><code class="language-rust">/// Whether the remote program enabled bracketed paste (DECSET 2004; bash/zsh/vim do).
pub fn is_bracketed_paste(&amp;self) -&gt; bool {
    self.term
        .mode()
        .contains(alacritty_terminal::term::TermMode::BRACKETED_PASTE)
}
</code></pre>
<p>The grid size must match the remote PTY. When the window is resized, the local <code>Term</code> is resized and the server is told through the SSH <code>window-change</code> channel request (RFC 4254 §6.7; <code>request_pty_size</code> in the <code>ssh2</code> crate). Otherwise remote programs keep wrapping at the old width.</p>
<h2>Stage 3: drawing the grid</h2>
<p>Alacritty itself renders glyphs with OpenGL. MistTerm is a full desktop application with side panels, an SFTP browser and dialogs, built on <code>egui</code> (an immediate-mode GUI) with the <code>wgpu</code> backend. So the terminal area is drawn with egui's text layout too.</p>
<img src="https://mistlab.dev/assets/images/ssh-terminal/f01-render.svg" alt="From PTY bytes to pixels in MistTerm" style="display:block;margin:0 auto" />

<p><em>From PTY bytes to pixels in MistTerm</em></p>
<p>For each visible row, cells are converted into an egui <code>LayoutJob</code>. Consecutive cells with identical styling are merged into one run, so the layout engine handles a few dozen text segments instead of thousands of single characters:</p>
<pre><code class="language-rust">let same_run = |a: &amp;TextFormat, b: &amp;TextFormat| {
    a.color == b.color
        &amp;&amp; a.extra_letter_spacing == b.extra_letter_spacing
        &amp;&amp; a.line_height == b.line_height
        &amp;&amp; a.font_id == b.font_id
};
// …
for (ch, color, _bg, flags) in row {
    if flags.intersects(wide_spacer) {
        flush_run(&amp;mut job, &amp;mut run_text, &amp;mut run_fmt);
        job.append("\u{200b}", 0.0, cell_fmt(color, 0.0));
        continue;
    }
    let spacing = if flags.contains(Flags::WIDE_CHAR) { cell_w.max(0.0) } else { 0.0 };
    // same format as the current run → push; otherwise flush and start a new run
}
</code></pre>
<p>Two details here exist because of CJK text:</p>
<ol>
<li><strong>Spacer cells.</strong> A wide character is stored as the character itself (flagged <code>WIDE_CHAR</code>) followed by a spacer cell (<code>WIDE_CHAR_SPACER</code>). The spacer must not draw anything, but every row has to keep the same number of cells. MistTerm emits a zero-width space for it.</li>
<li><strong>Forcing the advance width.</strong> A monospace font's CJK glyph is not guaranteed to be exactly twice the width of its ASCII glyphs. Laid out naively, the error accumulates along the line and the text drifts away from the cursor. Adding <code>extra_letter_spacing</code> to wide characters forces each one to occupy exactly two cell widths.</li>
</ol>
<p>Backgrounds are handled separately:</p>
<pre><code class="language-rust">// Glyph layer background is always transparent; ANSI and selection backgrounds are
// painted by the UI, so egui's background expansion can't overlap adjacent lines.
let cell_fmt = |color: Color32, extra_spacing: f32| TextFormat {
    font_id: FontId::monospace(font_size),
    color,
    background: Color32::TRANSPARENT,
    line_height: Some(line_height),
    extra_letter_spacing: extra_spacing,
    valign: egui::Align::BOTTOM,
    ..Default::default()
};
</code></pre>
<p>A text engine paints a "background color" around each glyph's bounding box, which can bleed into neighboring lines. A terminal needs solid blocks aligned exactly to the cell grid. So MistTerm paints background rectangles itself from grid coordinates, and the text layer only draws foreground glyphs.</p>
<h2>What is left to build</h2>
<p>With the emulator core borrowed, what remains is the glue: where the bytes come from, where keystrokes go, when to repaint, and selection, search and scrollback in the GUI. The next article starts with where the bytes come from: the SSH channel, the remote PTY and flow control.</p>
<h2>Summary</h2>
<ul>
<li>A terminal and its program share only a byte stream. Control sequences follow ECMA-48 and xterm conventions, and <code>$TERM</code> tells the program which dialect to speak.</li>
<li>Parsing is a resumable state machine (Williams' model, implemented by <code>vte</code>).</li>
<li>The grid carries the hard semantics: margins, wrap state, modes, wide characters, alternate screen. <code>alacritty_terminal</code> provides a mature implementation.</li>
<li>Rendering into a general GUI needs care: merge runs, give wide characters explicit spacing, and paint backgrounds per cell.</li>
</ul>
<h2>FAQ</h2>
<h3>What is the difference between the parser and the grid in a terminal emulator?</h3>
<p>The parser only turns bytes into actions such as "print a character" or "dispatch a CSI sequence". The grid gives those actions their meaning: it holds the character cells, the cursor, scrolling margins, modes and the alternate screen.</p>
<h3>What does TERM=xterm-256color do?</h3>
<p>It tells programs on the server which terminal type to assume. They look the name up in the terminfo database to decide which control sequences to emit. MistTerm requests a PTY with that terminal type.</p>
<h3>Why do CJK characters drift out of alignment in some terminals?</h3>
<p>A monospace font's CJK glyph is not guaranteed to be exactly twice as wide as its ASCII glyphs, so small errors accumulate along a line. MistTerm forces each wide character to occupy exactly two cell widths.</p>
<h3>Why does vim give the screen back when it exits?</h3>
<p>It switches to the alternate screen with DEC private mode 1049 and switches back on exit. The alternate screen has no scrollback. <strong>Related in this series</strong></p>
<ul>
<li><a href="ssh-terminal-02-ssh-channel.html">SSH Flow Control, and Why an SSH Client Must Keep Reading While It Writes</a></li>
<li><a href="ssh-terminal-03-paste.html">What Happens When You Paste into a Terminal: Bracketed Paste, the 4095-Byte Line, and UTF-8 Boundaries</a></li>
</ul>
<p><a href="ssh-terminal.html">Series index</a> · <a href="ssh-terminal-02-ssh-channel.html">Part 2</a> →</p>
<hr />
<p><em>Originally published at</em> <a href="https://mistlab.dev/stories/ssh-terminal-01-emulation.html"><em>https://mistlab.dev/stories/ssh-terminal-01-emulation.html</em></a><em>. Part of</em> <a href="https://mistlab.dev/stories/ssh-terminal.html"><em>Building an SSH Terminal</em></a><em>.</em></p>
]]></content:encoded></item></channel></rss>