<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: c-wind</title>
    <description>The latest articles on DEV Community by c-wind (@c-wind).</description>
    <link>https://dev.to/c-wind</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4197648%2Fcfb96644-7ff4-4fa3-b40f-cfda890c0a53.jpg</url>
      <title>DEV Community: c-wind</title>
      <link>https://dev.to/c-wind</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/c-wind"/>
    <language>en</language>
    <item>
      <title>Finding ZMODEM in a Terminal Byte Stream: How `rz`/`sz` Works Inside an SSH Client</title>
      <dc:creator>c-wind</dc:creator>
      <pubDate>Sat, 31 Oct 2026 14:03:32 +0000</pubDate>
      <link>https://dev.to/c-wind/finding-zmodem-in-a-terminal-byte-stream-how-rzsz-works-inside-an-ssh-client-6a2</link>
      <guid>https://dev.to/c-wind/finding-zmodem-in-a-terminal-byte-stream-how-rzsz-works-inside-an-ssh-client-6a2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://mistlab.dev/stories/ssh-terminal-05-zmodem.html" rel="noopener noreferrer"&gt;MistLab&lt;/a&gt;. Part 5 of &lt;a href="https://mistlab.dev/stories/ssh-terminal.html" rel="noopener noreferrer"&gt;&lt;em&gt;Building an SSH Terminal&lt;/em&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Part 5 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, and source comments are translated from Chinese.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;TL;DR&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ZMODEM&lt;/strong&gt;, implemented by the lrzsz tools &lt;code&gt;rz&lt;/code&gt; and &lt;code&gt;sz&lt;/code&gt;, is a file-transfer protocol that runs inside the terminal session itself, so it works over SSH without a second connection. The client has to find ZMODEM frame headers in ordinary output, then route the PTY stream to the protocol instead of the emulator. MistTerm only starts a transfer when it sees a valid header &lt;em&gt;and&lt;/em&gt; the user has just run &lt;code&gt;sz&lt;/code&gt; or &lt;code&gt;rz&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On many production servers the quickest way to move a file is still &lt;code&gt;sz file.tar.gz&lt;/code&gt; to download it, or &lt;code&gt;rz&lt;/code&gt; to upload one. These are the lrzsz tools, implementing ZMODEM, a file-transfer protocol from 1986 designed for modems and serial lines. It works over SSH because it never needs a separate connection. It runs inside the terminal session, over the same PTY bytes as everything else.&lt;/p&gt;

&lt;p&gt;That is also what makes it hard for a terminal. The client has to spot the start of a transfer inside a stream that is otherwise text for the screen. It then has to take the stream away from the emulator, run a protocol over it, and hand it back cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does ZMODEM work? Just enough of the protocol
&lt;/h2&gt;

&lt;p&gt;The protocol is described in Chuck Forsberg's ZMODEM specification. The parts that matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Frames start with a header. Header bytes include &lt;code&gt;ZPAD&lt;/code&gt; (&lt;code&gt;*&lt;/code&gt;) and &lt;code&gt;ZDLE&lt;/code&gt; (0x18, the same value as ASCII CAN), followed by a format byte. &lt;code&gt;B&lt;/code&gt; is a hex header, encoded entirely in printable hex digits and ended by CR LF. &lt;code&gt;A&lt;/code&gt; is binary with CRC-16, &lt;code&gt;C&lt;/code&gt; is binary with CRC-32. A hex header looks like &lt;code&gt;**&lt;/code&gt; ZDLE &lt;code&gt;B&lt;/code&gt; + two hex digits for the frame type + eight for flags + four for the CRC. Binary headers formally use a single &lt;code&gt;ZPAD&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Frame types relevant to startup: &lt;code&gt;ZRQINIT&lt;/code&gt; (type 0), sent by the sender to ask for a receiver; and &lt;code&gt;ZRINIT&lt;/code&gt; (type 1), sent by the receiver to say it is ready and to state its capabilities.&lt;/li&gt;
&lt;li&gt;ZDLE escaping. In binary data, ZDLE and certain control characters are escaped, so a frame survives links that interpret XON/XOFF.&lt;/li&gt;
&lt;li&gt;Cancellation. Five or more consecutive CAN (0x18) bytes abort a session.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The roles follow from the command. &lt;code&gt;sz&lt;/code&gt; on the server is the sender: it starts by printing &lt;code&gt;rz\r&lt;/code&gt; (a hint to terminals that auto-start a receiver) and a hex &lt;code&gt;ZRQINIT&lt;/code&gt;. The client must become the receiver and answer with &lt;code&gt;ZRINIT&lt;/code&gt;. &lt;code&gt;rz&lt;/code&gt; on the server is the receiver: it announces itself with &lt;code&gt;ZRINIT&lt;/code&gt; (lrzsz also prints the text "rz waiting to receive."). The client must become the sender and start with a file header (&lt;code&gt;ZFILE&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frdntmb4hiqcd7xy8v1me.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frdntmb4hiqcd7xy8v1me.png" alt="ZMODEM over a PTY: detection, then the protocol takes over the stream" width="799" height="765"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ZMODEM over a PTY: detection, then the protocol takes over the stream&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How does a terminal detect rz/sz? Not mistaking text for a frame
&lt;/h2&gt;

&lt;p&gt;A terminal that turns into a file receiver whenever &lt;code&gt;**\x18B00&lt;/code&gt; scrolls past would misfire on &lt;code&gt;cat&lt;/code&gt;-ing a binary file, on log output, or on someone viewing this article over SSH. MistTerm combines three signals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scan a bounded tail, not just the latest read.&lt;/strong&gt; A header can be split across reads (Part 2). Output from the session is appended to a detection tail, capped at the last 512 bytes, and the scanner runs over that window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;push_zmodem_detect_tail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TAIL_CAP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.zmodem_pty_detect_tail&lt;/span&gt;&lt;span class="nf"&gt;.extend_from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.zmodem_pty_detect_tail&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TAIL_CAP&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nb"&gt;drop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.zmodem_pty_detect_tail&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;TAIL_CAP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.zmodem_pty_detect_tail&lt;/span&gt;&lt;span class="nf"&gt;.drain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="nb"&gt;drop&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Parse headers structurally.&lt;/strong&gt; The scanners check the full prefix (&lt;code&gt;ZPAD ZPAD ZDLE&lt;/code&gt; + format byte) and decode the type digits. They accept the variants found in the wild, such as a parity-stripped &lt;code&gt;ZPAD&lt;/code&gt; (0x80), the single- and double-&lt;code&gt;ZPAD&lt;/code&gt; binary forms, and an ASCII &lt;code&gt;**B…&lt;/code&gt; form without ZDLE, which the code's comments attribute to the Windows build of lrzsz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;hex_scan_for_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// at least 6 bytes: `** ZDLE 'B' HH` (HH = frame type as a hex pair)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.saturating_sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;is_zpad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;is_zpad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nn"&gt;zmodem&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ZDLE&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;hb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hb&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nn"&gt;zmodem&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ZHEX&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;hb&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;b'B'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;hb&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;b'b'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hex_pair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Require intent.&lt;/strong&gt; A header alone does not start a download. The client also tracks the command line the user submits. If its first word is &lt;code&gt;sz&lt;/code&gt;/&lt;code&gt;lsz&lt;/code&gt; (any path, &lt;code&gt;.exe&lt;/code&gt; allowed), download detection is armed for 30 seconds. Uploads work the same way with &lt;code&gt;rz&lt;/code&gt;/&lt;code&gt;lrz&lt;/code&gt;, armed for 45 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;is_sz_shell_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;shell_word_basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;first_shell_word&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="nf"&gt;.to_ascii_lowercase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nd"&gt;matches!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"sz"&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"lsz"&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"sz.exe"&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"lsz.exe"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A download starts only when a &lt;code&gt;ZRQINIT&lt;/code&gt; is seen and that window is open. &lt;code&gt;echo sz&lt;/code&gt; and &lt;code&gt;where rz&lt;/code&gt; do not arm it. An upload starts on any of: rz's own prompt text ("rz waiting to receive.", "Awaiting rz", "rz rz rz"), the Windows ASCII &lt;code&gt;**B01&lt;/code&gt; form, or a header seen while an upload is expected. The text check is deliberately specific to lrzsz's messages. The header check is gated by intent.&lt;/p&gt;

&lt;p&gt;A naming note for readers of the source: some comments in this code path say that &lt;code&gt;rz&lt;/code&gt; "sends ZRQINIT". By the specification, the receiver sends &lt;code&gt;ZRINIT&lt;/code&gt;, and &lt;code&gt;ZRQINIT&lt;/code&gt; comes from the sender. We list this in the review notes for the series.&lt;/p&gt;

&lt;h2&gt;
  
  
  Taking over the stream
&lt;/h2&gt;

&lt;p&gt;Once a transfer starts, PTY output belongs to the protocol. Feeding ZMODEM frames to the terminal emulator would put binary garbage on screen and, worse, could be read as control sequences. The handover has a few constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One reader.&lt;/strong&gt; The shell pump thread owns the SSH channel (Part 2). A transfer must not open a second reader, which would race for bytes. Instead the pump forwards PTY output to the ZMODEM engine through a bypass:&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Uploads (local sz → remote rz): ZMODEM frames on the PTY can only reach this point through the SSH pump thread; they must be bypassed to the transfer engine, and the Channel must not be read again from another thread.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capture before the file dialog.&lt;/strong&gt; For an upload, the remote &lt;code&gt;rz&lt;/code&gt; is already talking when the user is still choosing a file. MistTerm starts capturing the handshake (&lt;code&gt;begin_rz_handshake_capture&lt;/code&gt;) as soon as it is detected, before opening the file picker. Otherwise the receiver's &lt;code&gt;ZRINIT&lt;/code&gt; is consumed by the emulator, and the sender waits for a header that already went by.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounded buffering.&lt;/strong&gt; While waiting for the first header, or while frames are being reassembled, incoming bytes are buffered up to 512 KiB (&lt;code&gt;RX_INCOMING_CAP&lt;/code&gt;). Past that the receive is aborted rather than growing without bound.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The protocol work itself (frame encoding, CRCs, ZDLE escaping, the ZRPOS/ZACK exchange) is done by the &lt;code&gt;zmodem2&lt;/code&gt; crate, vendored in the repository. For comparison testing, setting &lt;code&gt;MISTTERM_ZMODEM_USE_EXTERNAL_SZ=1&lt;/code&gt; makes MistTerm drive a local &lt;code&gt;sz&lt;/code&gt; binary instead of the built-in sender.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two safety details
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The remote picks the file name, so treat it as untrusted.&lt;/strong&gt; The file name arrives in the data subpacket that follows the sender's &lt;code&gt;ZFILE&lt;/code&gt; header. A hostile or buggy server could send &lt;code&gt;../../.bashrc&lt;/code&gt; or an absolute path. MistTerm keeps only the final path component and never overwrites:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;disambiguate_download_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;download_dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PathBuf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;String&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_utf8_lossy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="nf"&gt;.trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="nf"&gt;.file_name&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="nf"&gt;.to_str&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="nf"&gt;.filter&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"zmodem.bin"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;download_dir&lt;/span&gt;&lt;span class="nf"&gt;.join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;only&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1u32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="nf"&gt;.exists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// name_1.ext, name_2.ext, …&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Path::file_name&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt; for names ending in &lt;code&gt;..&lt;/code&gt;, which then fall back to &lt;code&gt;zmodem.bin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cancel must jump the queue.&lt;/strong&gt; When the user cancels an upload, the session queue may still hold file data. A single Ctrl+C is not guaranteed to stop &lt;code&gt;rz&lt;/code&gt;/&lt;code&gt;sz&lt;/code&gt;, because while they run the PTY is in raw mode. The cancel goes through the priority queue described in Part 2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In ZMODEM protocol state a single Ctrl+C may not be recognized by the remote&lt;/span&gt;
&lt;span class="c1"&gt;// rz/sz; send the CAN sequence at high priority, then ETX.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="mi"&gt;0x18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// CAN x8&lt;/span&gt;
    &lt;span class="mi"&gt;0x08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// erase noise&lt;/span&gt;
    &lt;span class="mi"&gt;0x03&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ETX&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="nf"&gt;.send_priority_interrupt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight CANs exceed the spec's five-CAN abort threshold. The backspaces mirror lrzsz's own cancel string (CANs followed by backspaces), erasing the CANs if the line ends up back at a shell. The final ETX covers the case where the remote program has already exited.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;ZMODEM runs inside the terminal stream. The client has to find frame headers within ordinary output.&lt;/li&gt;
&lt;li&gt;Detection needs a bounded scan window, a real header parse, and proof the user just submitted &lt;code&gt;sz&lt;/code&gt;/&lt;code&gt;rz&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;During a transfer, the PTY stream must bypass the emulator through the single channel owner, with bounded buffers.&lt;/li&gt;
&lt;li&gt;Treat the sender's file name as untrusted, and send cancellation ahead of queued data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How does rz/sz work over SSH?
&lt;/h3&gt;

&lt;p&gt;ZMODEM runs inside the terminal session over the same PTY bytes as normal output, so no separate connection is needed. The terminal must detect the start of a transfer and take the stream over.&lt;/p&gt;

&lt;h3&gt;
  
  
  What do ZRQINIT and ZRINIT mean?
&lt;/h3&gt;

&lt;p&gt;ZRQINIT (frame type 0) is sent by the sender to ask for a receiver. ZRINIT (frame type 1) is sent by the receiver to say it is ready and to state its capabilities.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why doesn't MistTerm start a download whenever it sees a ZMODEM header?
&lt;/h3&gt;

&lt;p&gt;Headers can appear in ordinary output, such as a binary file printed with cat. MistTerm also requires that the user has just submitted an sz or rz command.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you cancel a ZMODEM transfer?
&lt;/h3&gt;

&lt;p&gt;Five or more consecutive CAN (0x18) bytes abort a session. MistTerm sends eight, ahead of any queued file data.&lt;br&gt;
  &lt;strong&gt;Related in this series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://mistlab.dev/stories/ssh-terminal-02-ssh-channel.html" rel="noopener noreferrer"&gt;SSH Flow Control, and Why an SSH Client Must Keep Reading While It Writes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mistlab.dev/stories/ssh-terminal-06-cmd-audit.html" rel="noopener noreferrer"&gt;Command Auditing in an SSH Terminal: What the Client Can See, and What Only the Server Can Enforce&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;← &lt;a href="https://mistlab.dev/stories/ssh-terminal-04-locale.html" rel="noopener noreferrer"&gt;Part 4&lt;/a&gt; · &lt;a href="https://mistlab.dev/stories/ssh-terminal.html" rel="noopener noreferrer"&gt;Series index&lt;/a&gt; · &lt;a href="https://mistlab.dev/stories/ssh-terminal-06-cmd-audit.html" rel="noopener noreferrer"&gt;Part 6&lt;/a&gt; →&lt;/p&gt;

</description>
      <category>rust</category>
      <category>ssh</category>
      <category>linux</category>
      <category>terminal</category>
    </item>
    <item>
      <title>Why Non-ASCII Input Breaks over SSH: Locales, SendEnv and the `env` Request</title>
      <dc:creator>c-wind</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:41:19 +0000</pubDate>
      <link>https://dev.to/c-wind/why-non-ascii-input-breaks-over-ssh-locales-sendenv-and-the-env-request-506p</link>
      <guid>https://dev.to/c-wind/why-non-ascii-input-breaks-over-ssh-locales-sendenv-and-the-env-request-506p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://mistlab.dev/stories/ssh-terminal-04-locale.html" rel="noopener noreferrer"&gt;MistLab&lt;/a&gt;. Part 4 of &lt;a href="https://mistlab.dev/stories/ssh-terminal.html" rel="noopener noreferrer"&gt;&lt;em&gt;Building an SSH Terminal&lt;/em&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Part 4 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&lt;/em&gt; &lt;/p&gt;

&lt;p&gt;TL;DR&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Garbled non-ASCII input&lt;/strong&gt; over SSH usually means the remote program runs in the POSIX/C locale, so line editors such as readline treat each byte as a character. The locale reaches the server through the RFC 4254 &lt;code&gt;env&lt;/code&gt; request, which sshd accepts only for variables listed in &lt;code&gt;AcceptEnv&lt;/code&gt;. Upstream OpenSSH sends no variables by default; Debian-family packages add &lt;code&gt;SendEnv LANG LC_*&lt;/code&gt; and the matching &lt;code&gt;AcceptEnv&lt;/code&gt;. The fix is to forward a valid UTF-8 locale and to allow it on the server. &lt;/p&gt;

&lt;p&gt;A common SSH puzzle goes like this. Output in Chinese, Japanese or with accented letters displays fine. But when you type such characters at a bash or mysql prompt, the line fills with garbage, or Backspace deletes half a character. The terminal is not at fault. The server-side program is running in the wrong locale, and the fix is a little-known part of the SSH protocol. &lt;/p&gt;

&lt;h2&gt;
  
  
  Why does non-ASCII input break over SSH? Bytes are only text inside a locale
&lt;/h2&gt;

&lt;p&gt;Part 1 established that the terminal and the remote program exchange bytes. Turning bytes into characters is the program's job, and C programs do it through the C library's locale. After &lt;code&gt;setlocale(LC_ALL, "")&lt;/code&gt;, the &lt;code&gt;LC_CTYPE&lt;/code&gt; category, chosen from the environment variables &lt;code&gt;LC_ALL&lt;/code&gt;, &lt;code&gt;LC_CTYPE&lt;/code&gt; and &lt;code&gt;LANG&lt;/code&gt; in that order of precedence, decides the character encoding. Under &lt;code&gt;en_US.UTF-8&lt;/code&gt;, the three bytes &lt;code&gt;E4 B8 AD&lt;/code&gt; are one character, &lt;code&gt;中&lt;/code&gt;, occupying two columns. Under the &lt;code&gt;C&lt;/code&gt;/&lt;code&gt;POSIX&lt;/code&gt; locale they are three unrelated bytes. &lt;/p&gt;

&lt;p&gt;Output usually survives a wrong locale because most programs pass bytes through unchanged, and the terminal decodes UTF-8 itself. Line editors do not. Readline (bash) and libedit (mysql, sqlite3 on some systems) have to know where each character starts in order to echo, move the cursor, redraw and delete. In the C locale, they treat every byte as a separate character. Depending on configuration (&lt;code&gt;input-meta&lt;/code&gt;, &lt;code&gt;output-meta&lt;/code&gt;), the bytes are echoed as octal escapes or treated as Meta-modified keys, and Backspace removes one byte of a multi-byte character. That is the garbage you see. &lt;/p&gt;

&lt;h2&gt;
  
  
  Where the remote locale comes from
&lt;/h2&gt;

&lt;p&gt;An SSH session's environment is assembled on the server. sshd sets a few variables itself, PAM modules may add more (on Debian and Ubuntu, &lt;code&gt;pam_env&lt;/code&gt; in &lt;code&gt;/etc/pam.d/sshd&lt;/code&gt; reads &lt;code&gt;/etc/default/locale&lt;/code&gt;), and the login shell's profile scripts can set more. Minimal images, containers and some distributions end up with no &lt;code&gt;LANG&lt;/code&gt; at all, which means the POSIX locale. &lt;/p&gt;

&lt;p&gt;The client can also contribute, through the &lt;code&gt;env&lt;/code&gt; channel request in RFC 4254 §6.4:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;byte      SSH_MSG_CHANNEL_REQUEST
uint32    recipient channel
string    "env"
boolean   want reply
string    variable name
string    variable value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The RFC warns that passing arbitrary variables is a security risk and that servers should restrict them. OpenSSH's sshd accepts nothing by default. Variables have to be listed with &lt;code&gt;AcceptEnv&lt;/code&gt; in &lt;code&gt;sshd_config&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F13t3p4spk89j45qhjbrl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F13t3p4spk89j45qhjbrl.png" alt="Locale forwarding: env requests are sent between pty-req and shell" width="799" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Locale forwarding: env requests are sent between pty-req and shell&lt;/em&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  Does ssh send your locale by default?
&lt;/h2&gt;

&lt;p&gt;It is widely believed that the &lt;code&gt;ssh&lt;/code&gt; client "sends your locale". Strictly, that is a distribution default, not an OpenSSH default. The upstream &lt;code&gt;ssh_config(5)&lt;/code&gt; manual says of &lt;code&gt;SendEnv&lt;/code&gt;: "The default is not to send any environment variables." The one exception is &lt;code&gt;TERM&lt;/code&gt;, which is not sent through &lt;code&gt;env&lt;/code&gt; at all; it travels inside &lt;code&gt;pty-req&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;What most users see comes from distribution packaging. Debian's OpenSSH package, and therefore Ubuntu's, ships &lt;code&gt;/etc/ssh/ssh_config&lt;/code&gt; with&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;SendEnv LANG LC_&lt;span class="k"&gt;*&lt;/span&gt; COLORTERM NO_COLOR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; with the matching&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;AcceptEnv&lt;/span&gt; LANG LC_* COLORTERM NO_COLOR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other distributions ship similar pairs. So between two stock Debian-family machines the locale does travel, and that is the behavior a third-party client should reproduce. A client that sends nothing will behave worse than &lt;code&gt;ssh&lt;/code&gt; on exactly the servers where &lt;code&gt;AcceptEnv&lt;/code&gt; is configured. &lt;/p&gt;

&lt;p&gt;(MistTerm's own source comment describes this as "the OpenSSH default". That is imprecise in the way described above, and we list it in the review notes for this series.) &lt;/p&gt;

&lt;h2&gt;
  
  
  Forwarding the locale: the details
&lt;/h2&gt;

&lt;p&gt;MistTerm sends &lt;code&gt;env&lt;/code&gt; requests after &lt;code&gt;pty-req&lt;/code&gt; and before &lt;code&gt;shell&lt;/code&gt;. Order matters, because once the shell has started, its environment is fixed. Which variables to send, and what to do when there are none, is decided in &lt;code&gt;remote_locale_env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/ssh/client.rs&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;remote_locale_env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nf"&gt;Fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NAMES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s"&gt;"LANG"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"LC_ALL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"LC_CTYPE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"LC_COLLATE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"LC_MESSAGES"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"LC_MONETARY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"LC_NUMERIC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"LC_TIME"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;plausible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// macOS Terminal often sets `LC_CTYPE=UTF-8`. That is a macOS-only form that&lt;/span&gt;
        &lt;span class="c1"&gt;// glibc does not accept; sending it makes the remote setlocale fail as a&lt;/span&gt;
        &lt;span class="c1"&gt;// whole and fall back to POSIX, so treat it as unset.&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;bare_codeset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="nf"&gt;.eq_ignore_ascii_case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UTF-8"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="nf"&gt;.eq_ignore_ascii_case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UTF8"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;bare_codeset&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="nf"&gt;.chars&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="nf"&gt;.all&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="nf"&gt;.is_ascii_alphanumeric&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="nd"&gt;matches!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sc"&gt;'.'&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="sc"&gt;'_'&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="sc"&gt;'-'&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="sc"&gt;'@'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NAMES&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.filter_map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="nf"&gt;.trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;plausible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.then&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;has_ctype_source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.any&lt;/span&gt;&lt;span class="p"&gt;(|(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)|&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"LANG"&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"LC_ALL"&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"LC_CTYPE"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;has_ctype_source&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"LANG"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_REMOTE_LANG&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt; &lt;span class="c1"&gt;// "en_US.UTF-8"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three cases drive the design: &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Values that only make sense locally. macOS commonly exports &lt;code&gt;LC_CTYPE=UTF-8&lt;/code&gt;, a bare codeset that the macOS C library accepts. glibc does not. With &lt;code&gt;setlocale(LC_ALL, "")&lt;/code&gt;, one invalid category makes the whole call fail and the program stays in the C locale. Forwarding the variable would cause exactly the problem it was meant to fix, so bare &lt;code&gt;UTF-8&lt;/code&gt;/&lt;code&gt;UTF8&lt;/code&gt; values are dropped. &lt;/li&gt;
&lt;li&gt;Values that are not locale names. Variables are user-controlled, so only short values made of the characters that appear in locale names (&lt;code&gt;[A-Za-z0-9._@-]&lt;/code&gt;, 64 bytes at most) are sent. &lt;/li&gt;
&lt;li&gt;No locale at all. A Windows desktop normally has no &lt;code&gt;LANG&lt;/code&gt;, and neither does a GUI app launched without a login environment. If none of &lt;code&gt;LANG&lt;/code&gt;, &lt;code&gt;LC_ALL&lt;/code&gt; or &lt;code&gt;LC_CTYPE&lt;/code&gt; survives, the client sends &lt;code&gt;LANG=en_US.UTF-8&lt;/code&gt;, so that an otherwise unconfigured server still gets a UTF-8 &lt;code&gt;LC_CTYPE&lt;/code&gt;. &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each request is sent independently, and a rejection is not an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;remote_locale_env&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="nf"&gt;.setenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;log&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;info!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SSH setenv {}={}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;log&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;info!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SSH setenv {} rejected by server: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How to fix it: what the client can do, and what the server must allow
&lt;/h2&gt;

&lt;p&gt;Forwarding only helps if the server cooperates in two ways: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;AcceptEnv&lt;/code&gt; must allow the variable. Without it, sshd refuses the request and the session falls back to whatever the server's own configuration provides. That is the same result as with OpenSSH's &lt;code&gt;ssh&lt;/code&gt;. The fix is on the server: add &lt;code&gt;AcceptEnv LANG LC_*&lt;/code&gt;, or set a system default locale. &lt;/li&gt;
&lt;li&gt;The locale must exist on the server. If &lt;code&gt;en_US.UTF-8&lt;/code&gt; is not generated on that machine, &lt;code&gt;setlocale&lt;/code&gt; fails again. On glibc systems, &lt;code&gt;locale -a&lt;/code&gt; lists what is installed, and many minimal systems also provide &lt;code&gt;C.UTF-8&lt;/code&gt;. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you debug a server, &lt;code&gt;locale&lt;/code&gt; inside the session shows what the remote programs actually got. &lt;/p&gt;

&lt;p&gt;For MistTerm's local shell tab (a PTY on your own machine, no SSH involved), the same concern is handled by setting the environment directly: &lt;code&gt;TERM=xterm-256color&lt;/code&gt; and &lt;code&gt;COLORTERM=truecolor&lt;/code&gt; always, and on macOS &lt;code&gt;LANG=en_US.UTF-8&lt;/code&gt; when neither &lt;code&gt;LANG&lt;/code&gt; nor &lt;code&gt;LC_ALL&lt;/code&gt; is set. An app launched from Finder or the Dock does not inherit a login environment, which is the case this covers. &lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Line editors need a correct &lt;code&gt;LC_CTYPE&lt;/code&gt; to handle multi-byte input. In the POSIX locale they work byte by byte. &lt;/li&gt;
&lt;li&gt;The locale reaches the server through RFC 4254 &lt;code&gt;env&lt;/code&gt; requests, sent before &lt;code&gt;shell&lt;/code&gt; and filtered by sshd's &lt;code&gt;AcceptEnv&lt;/code&gt;. &lt;/li&gt;
&lt;li&gt;Upstream OpenSSH sends no variables by default. Debian-family packaging adds &lt;code&gt;SendEnv LANG LC_*&lt;/code&gt; (plus &lt;code&gt;COLORTERM&lt;/code&gt;, &lt;code&gt;NO_COLOR&lt;/code&gt;) and the matching &lt;code&gt;AcceptEnv&lt;/code&gt;. &lt;/li&gt;
&lt;li&gt;Filter values the remote libc will reject, such as macOS's bare &lt;code&gt;UTF-8&lt;/code&gt;, and supply a UTF-8 default when the local machine has none. &lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does typed Chinese or accented text turn into garbage over SSH?
&lt;/h3&gt;

&lt;p&gt;The remote program is usually running in the POSIX/C locale, so line editors such as readline handle input byte by byte instead of character by character. &lt;/p&gt;

&lt;h3&gt;
  
  
  Does ssh send LANG to the server by default?
&lt;/h3&gt;

&lt;p&gt;Upstream OpenSSH sends no environment variables by default. Debian and Ubuntu packages configure SendEnv LANG LC_* in ssh_config and the matching AcceptEnv in sshd_config. &lt;/p&gt;

&lt;h3&gt;
  
  
  How do I fix garbled input on the server side?
&lt;/h3&gt;

&lt;p&gt;Allow the variables with AcceptEnv LANG LC_* in sshd_config, or set a system default locale, and make sure the locale exists on the server (check with locale -a). &lt;/p&gt;

&lt;h3&gt;
  
  
  Why should a client not forward LC_CTYPE=UTF-8 from macOS?
&lt;/h3&gt;

&lt;p&gt;glibc does not accept a bare codeset as a locale name. One invalid category makes setlocale(LC_ALL, "") fail, and the program stays in the C locale.&lt;br&gt;
  &lt;strong&gt;Related in this series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://mistlab.dev/stories/ssh-terminal-01-emulation.html" rel="noopener noreferrer"&gt;From Bytes to Glyphs: How a Terminal Emulator Actually Works&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mistlab.dev/stories/ssh-terminal-03-paste.html" rel="noopener noreferrer"&gt;What Happens When You Paste into a Terminal: Bracketed Paste, the 4095-Byte Line, and UTF-8 Boundaries&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;← &lt;a href="https://mistlab.dev/stories/ssh-terminal-03-paste.html" rel="noopener noreferrer"&gt;Part 3&lt;/a&gt; · &lt;a href="https://mistlab.dev/stories/ssh-terminal.html" rel="noopener noreferrer"&gt;Series index&lt;/a&gt; · &lt;a href="https://mistlab.dev/stories/ssh-terminal-05-zmodem.html" rel="noopener noreferrer"&gt;Part 5&lt;/a&gt; →&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>terminal</category>
      <category>rust</category>
      <category>linux</category>
    </item>
    <item>
      <title>What Happens When You Paste into a Terminal: Bracketed Paste, the 4095-Byte Line, and UTF-8 Boundaries</title>
      <dc:creator>c-wind</dc:creator>
      <pubDate>Wed, 28 Oct 2026 14:32:35 +0000</pubDate>
      <link>https://dev.to/c-wind/what-happens-when-you-paste-into-a-terminal-bracketed-paste-the-4095-byte-line-and-utf-8-3pha</link>
      <guid>https://dev.to/c-wind/what-happens-when-you-paste-into-a-terminal-bracketed-paste-the-4095-byte-line-and-utf-8-3pha</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://mistlab.dev/stories/ssh-terminal-03-paste.html" rel="noopener noreferrer"&gt;MistLab&lt;/a&gt;. Part 3 of &lt;a href="https://mistlab.dev/stories/ssh-terminal.html" rel="noopener noreferrer"&gt;&lt;em&gt;Building an SSH Terminal&lt;/em&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;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&lt;/em&gt; &lt;/p&gt;

&lt;p&gt;TL;DR&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bracketed paste&lt;/strong&gt; is an xterm mode (DEC private mode 2004). Once a program enables it, the terminal wraps each paste in &lt;code&gt;ESC[200~&lt;/code&gt; … &lt;code&gt;ESC[201~&lt;/code&gt;, 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 &lt;code&gt;n_tty&lt;/code&gt; 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. &lt;/p&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: a pasted newline is an Enter. How does bracketed paste work?
&lt;/h2&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;The fix is &lt;strong&gt;bracketed paste&lt;/strong&gt;, an xterm extension documented in ctlseqs. A program that wants it enables DEC private mode 2004 (&lt;code&gt;CSI ? 2004 h&lt;/code&gt;). From then on, the terminal wraps each paste:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ESC [ 2 0 0 ~   &amp;lt;pasted text&amp;gt;   ESC [ 2 0 1 ~
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe4em1pn08mpbrv1kw880.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe4em1pn08mpbrv1kw880.png" alt="Bracketed paste: the program asks for mode 2004, the terminal wraps the paste" width="800" height="565"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Bracketed paste: the program asks for mode 2004, the terminal wraps the paste&lt;/em&gt; &lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;:help xterm-bracketed-paste&lt;/code&gt;, inserts it without auto-indent. &lt;/p&gt;

&lt;p&gt;In GNU Readline, the library behind bash's prompt, bracketed paste became &lt;strong&gt;on by default in readline 8.1&lt;/strong&gt;. The NEWS entry reads "Bracketed paste mode is enabled by default", and the source shows the switch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* readline-8.1, rlprivate.h */&lt;/span&gt;
&lt;span class="cp"&gt;#define BRACKETED_PASTE_DEFAULT 1
&lt;/span&gt;&lt;span class="cm"&gt;/* readline-8.1, readline.c */&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;_rl_enable_bracketed_paste&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BRACKETED_PASTE_DEFAULT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In readline 8.0 the same variable is initialized to &lt;code&gt;0&lt;/code&gt;. Bash 5.1 ships readline 8.1, so on current distributions an interactive bash asks for mode 2004 unless the user has set &lt;code&gt;enable-bracketed-paste off&lt;/code&gt; in &lt;code&gt;.inputrc&lt;/code&gt;. Readline also leaves the mode off for terminals it considers unsuitable (see &lt;code&gt;terminal.c&lt;/code&gt;), such as &lt;code&gt;TERM=dumb&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/ui/terminal_paste.rs&lt;/span&gt;
&lt;span class="cd"&gt;/// Normalize newlines to `\n`; when bracketed paste is on, wrap in&lt;/span&gt;
&lt;span class="cd"&gt;/// `ESC[200~ … ESC[201~` and strip forged markers from the body&lt;/span&gt;
&lt;span class="cd"&gt;/// (so pasted content cannot "end the paste" early and be run as commands).&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;build_paste_payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bracketed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="nf"&gt;.replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'\r'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;bracketed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="nf"&gt;.into_bytes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="nf"&gt;.replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x1b&lt;/span&gt;&lt;span class="s"&gt;[200~"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x1b&lt;/span&gt;&lt;span class="s"&gt;[201~"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Vec&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;with_capacity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;BP_START&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;BP_END&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="nf"&gt;.extend_from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BP_START&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="nf"&gt;.extend_from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="nf"&gt;.as_bytes&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="nf"&gt;.extend_from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BP_END&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The marker stripping is the security-relevant line. If clipboard text contains a literal &lt;code&gt;ESC[201~&lt;/code&gt;, 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_paste_payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ls&lt;/span&gt;&lt;span class="se"&gt;\x1b&lt;/span&gt;&lt;span class="s"&gt;[201~rm -rf x&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nd"&gt;assert_eq!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;b"&lt;/span&gt;&lt;span class="se"&gt;\x1b&lt;/span&gt;&lt;span class="s"&gt;[200~lsrm -rf x&lt;/span&gt;&lt;span class="se"&gt;\n\x1b&lt;/span&gt;&lt;span class="s"&gt;[201~"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bracketed paste only helps if the receiving program enabled it. In a program that did not (a plain &lt;code&gt;cat&lt;/code&gt;, a script reading with &lt;code&gt;read&lt;/code&gt;, a REPL without support), a pasted newline is still an Enter. That is a property of the program, not something a terminal can change. &lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: why are long pasted lines cut at 4095 bytes?
&lt;/h2&gt;

&lt;p&gt;Programs that leave the TTY in &lt;strong&gt;canonical mode&lt;/strong&gt; (POSIX termios &lt;code&gt;ICANON&lt;/code&gt;) do not edit lines themselves. &lt;code&gt;cat&lt;/code&gt;, the shell builtin &lt;code&gt;read&lt;/code&gt; and Python's &lt;code&gt;input()&lt;/code&gt; 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. &lt;/p&gt;

&lt;p&gt;On Linux the buffer is defined in &lt;code&gt;drivers/tty/n_tty.c&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#define N_TTY_BUF_SIZE 4096
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;n_tty_receive_buf_common&lt;/code&gt; 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 &lt;strong&gt;one canonical-mode line holds at most 4095 bytes plus its newline&lt;/strong&gt;. Anything beyond is silently lost, and the reading program never knows. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5li64cdqc5oeczbt95hc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5li64cdqc5oeczbt95hc.png" alt="Canonical-mode line buffer on Linux" width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Canonical-mode line buffer on Linux&lt;/em&gt; &lt;/p&gt;

&lt;p&gt;Two details are easy to get wrong: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;MAX_CANON&lt;/code&gt; constant in Linux's &lt;code&gt;&amp;lt;linux/limits.h&amp;gt;&lt;/code&gt; is 255. It is not the limit that &lt;code&gt;n_tty&lt;/code&gt; enforces. POSIX only requires &lt;code&gt;MAX_CANON&lt;/code&gt; to be at least 255. &lt;/li&gt;
&lt;li&gt;The number is Linux-specific. macOS and the BSDs have a different TTY implementation, and macOS's &lt;code&gt;&amp;lt;sys/syslimits.h&amp;gt;&lt;/code&gt; declares &lt;code&gt;MAX_CANON&lt;/code&gt; as 1024. We have not measured macOS's actual behavior, so this series makes no claim about it beyond the header value. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;em&gt;not&lt;/em&gt; enabled bracketed paste and some line in the clipboard is longer than 4095 bytes, it shows a warning before sending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;bracketed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.terminal&lt;/span&gt;&lt;span class="nf"&gt;.is_bracketed_paste&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// In line mode (cat/read, …) a single line &amp;gt; 4095 bytes is truncated by the&lt;/span&gt;
&lt;span class="c1"&gt;// tty layer; the terminal can't avoid that, so tell the user.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;bracketed&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;exceeds_canon_line_limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.pending_toast_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* suggest SFTP upload or pasting in vim */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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. &lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: chunking without breaking characters
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;PASTE_CHUNK = 4096&lt;/code&gt; 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 &lt;code&gt;10xxxxxx&lt;/code&gt;: &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkiob8o2ogj7hbgxxzss3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkiob8o2ogj7hbgxxzss3.png" alt="Backing up to a UTF-8 character boundary" width="800" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Backing up to a UTF-8 character boundary&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="cd"&gt;/// Next chunk length: at most `max`, never splitting a UTF-8 multi-byte&lt;/span&gt;
&lt;span class="cd"&gt;/// character (unless a single character exceeds `max`).&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;next_chunk_len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// UTF-8 continuation bytes look like 10xxxxxx; back up to a character start.&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;0xC0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0x80&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that the check is on &lt;code&gt;buf[n]&lt;/code&gt;, the first byte of the &lt;em&gt;next&lt;/em&gt; 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. &lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;GAP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_millis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;RETRY_GAP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_millis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MAX_RETRIES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// give up after ≈10 s without progress&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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. &lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pasted newlines are indistinguishable from Enter unless the program enables bracketed paste (mode 2004). Readline enables it by default from 8.1 onward. &lt;/li&gt;
&lt;li&gt;A terminal implementing bracketed paste must strip &lt;code&gt;ESC[200~&lt;/code&gt;/&lt;code&gt;ESC[201~&lt;/code&gt; from the pasted text, or the clipboard can break out of the paste. &lt;/li&gt;
&lt;li&gt;Linux canonical-mode lines are capped at 4095 bytes plus newline by &lt;code&gt;n_tty&lt;/code&gt;. &lt;code&gt;MAX_CANON&lt;/code&gt; (255) is not that limit, and other kernels differ. &lt;/li&gt;
&lt;li&gt;Chunk large writes on UTF-8 boundaries, and only drop a chunk after it has been accepted. &lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How does bracketed paste work?
&lt;/h3&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;h3&gt;
  
  
  Is bracketed paste enabled by default in bash?
&lt;/h3&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;h3&gt;
  
  
  Why must a terminal strip ESC[201~ from pasted text?
&lt;/h3&gt;

&lt;p&gt;If the clipboard contains the end marker, the rest of the paste would be treated as typed input, including its newlines. &lt;/p&gt;

&lt;h3&gt;
  
  
  Why are pasted lines longer than 4095 bytes cut off on Linux?
&lt;/h3&gt;

&lt;p&gt;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.&lt;br&gt;
  &lt;strong&gt;Related in this series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://mistlab.dev/stories/ssh-terminal-02-ssh-channel.html" rel="noopener noreferrer"&gt;SSH Flow Control, and Why an SSH Client Must Keep Reading While It Writes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mistlab.dev/stories/ssh-terminal-04-locale.html" rel="noopener noreferrer"&gt;Why Non-ASCII Input Breaks over SSH: Locales, SendEnv and the &lt;code&gt;env&lt;/code&gt; Request&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;← &lt;a href="https://mistlab.dev/stories/ssh-terminal-02-ssh-channel.html" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt; · &lt;a href="https://mistlab.dev/stories/ssh-terminal.html" rel="noopener noreferrer"&gt;Series index&lt;/a&gt; · &lt;a href="https://mistlab.dev/stories/ssh-terminal-04-locale.html" rel="noopener noreferrer"&gt;Part 4&lt;/a&gt; →&lt;/p&gt;

</description>
      <category>rust</category>
      <category>ssh</category>
      <category>linux</category>
      <category>terminal</category>
    </item>
    <item>
      <title>SSH Flow Control, and Why an SSH Client Must Keep Reading While It Writes</title>
      <dc:creator>c-wind</dc:creator>
      <pubDate>Mon, 26 Oct 2026 15:32:34 +0000</pubDate>
      <link>https://dev.to/c-wind/ssh-flow-control-and-why-an-ssh-client-must-keep-reading-while-it-writes-282m</link>
      <guid>https://dev.to/c-wind/ssh-flow-control-and-why-an-ssh-client-must-keep-reading-while-it-writes-282m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://mistlab.dev/stories/ssh-terminal-02-ssh-channel.html" rel="noopener noreferrer"&gt;MistLab&lt;/a&gt;. Part 2 of &lt;a href="https://mistlab.dev/stories/ssh-terminal.html" rel="noopener noreferrer"&gt;&lt;em&gt;Building an SSH Terminal&lt;/em&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;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&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;TL;DR&lt;/p&gt;

&lt;p&gt;SSH flow control gives each direction of each channel a &lt;strong&gt;window&lt;/strong&gt;: the number of bytes the sender may still send before the receiver grants more with &lt;code&gt;SSH_MSG_CHANNEL_WINDOW_ADJUST&lt;/code&gt; (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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data path
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmistlab.dev%2Fassets%2Fimages%2Fssh-terminal%2Ff02-dataflow.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmistlab.dev%2Fassets%2Fimages%2Fssh-terminal%2Ff02-dataflow.svg" alt="Data path between the UI, the session thread and the remote shell" width="760" height="759"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Data path between the UI, the session thread and the remote shell&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When you type &lt;code&gt;l&lt;/code&gt;, the UI encodes the key and hands the bytes to the session layer. The SSH library encrypts them into an &lt;code&gt;SSH_MSG_CHANNEL_DATA&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Setting up that path takes three channel requests, all defined in RFC 4254. &lt;code&gt;pty-req&lt;/code&gt; (§6.2) carries the terminal type, the size in characters and pixels, and encoded terminal modes. Optional &lt;code&gt;env&lt;/code&gt; requests (§6.4) follow; Part 4 covers them. Finally &lt;code&gt;shell&lt;/code&gt; (§6.5) starts the user's login shell. Resizing later uses &lt;code&gt;window-change&lt;/code&gt; (§6.7). Despite the name, "window" there means the terminal window. It has nothing to do with the flow-control window below.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does the SSH channel window work?
&lt;/h2&gt;

&lt;p&gt;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 &lt;em&gt;window&lt;/em&gt;: the number of bytes the sender may still transmit. Sending data consumes window. The receiver adds window back with &lt;code&gt;SSH_MSG_CHANNEL_WINDOW_ADJUST&lt;/code&gt; after it has consumed data. When the window reaches zero, the sender must stop until an adjust arrives.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ds3daz7t18ybhhbnbn9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ds3daz7t18ybhhbnbn9.png" alt="SSH channel window: writes stall at zero until the peer adjusts" width="800" height="572"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;SSH channel window: writes stall at zero until the peer adjusts&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;writing&lt;/em&gt; 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 &lt;code&gt;write()&lt;/code&gt;, stops reading its input, and the client's send window stays at zero. Neither side can make progress. A writer has to keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Avoiding the write deadlock: write with drain
&lt;/h2&gt;

&lt;p&gt;MistTerm puts this rule in one function used for all large PTY writes, &lt;code&gt;write_pty_with_drain&lt;/code&gt; in &lt;code&gt;src/ssh/manager.rs&lt;/code&gt;. The session runs in non-blocking mode (&lt;code&gt;sess.set_blocking(false)&lt;/code&gt;), so a write either makes progress or returns immediately. The core of the loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MAX_NO_PROGRESS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// × 150µs sleep ≈ 0.9 s without progress → error&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;CHUNK_CEIL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;len_before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;win&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="nf"&gt;.write_window&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.remaining&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;win&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// no send window: read output instead, so the peer can make progress&lt;/span&gt;
        &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;pump_channel_reads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;upload_bypass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;win&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CHUNK_CEIL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="nf"&gt;.write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* drain reads */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* drain reads between chunks */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;is_retryable_write_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* drain reads */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;len_before&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;no_progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;no_progress&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;no_progress&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MAX_NO_PROGRESS&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* TimedOut */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nn"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_micros&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The real code maps a closed channel to &lt;code&gt;ConnectionAborted&lt;/code&gt; at each drain point and handles a library return value larger than the request. These are elided here.)&lt;/p&gt;

&lt;p&gt;Three design choices are visible:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never write more than the window.&lt;/strong&gt; The chunk size is capped by &lt;code&gt;write_window().remaining&lt;/code&gt; and by a fixed ceiling, so a write call never has more queued than the protocol allows. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read whenever writing cannot proceed.&lt;/strong&gt; A zero window, a zero-byte write and a would-block error all lead to &lt;code&gt;pump_channel_reads&lt;/code&gt;. Output is forwarded to the terminal as it arrives, so the window in the other direction keeps flowing. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound the wait.&lt;/strong&gt; 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. &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Keep the network off the UI thread
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;MistTerm gives each interactive session a dedicated OS thread, the &lt;em&gt;shell pump&lt;/em&gt;, which alone owns the channel. The UI communicates with it only through bounded queues:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pump_tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pump_rx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;sync_channel&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ShellPumpCommand&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SHELL_PUMP_QUEUE_CAP&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 512&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interrupt_tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interrupt_rx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;sync_channel&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resize_tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resize_rx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;sync_channel&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RESIZE_QUEUE_CAP&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// 16&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The UI side never blocks on these queues. Every enqueue uses &lt;code&gt;try_send&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="cd"&gt;/// Uses `try_send` (non-blocking): if the pump thread is stuck in&lt;/span&gt;
&lt;span class="cd"&gt;/// `write_pty_with_drain` or a long read and the queue is full, the caller&lt;/span&gt;
&lt;span class="cd"&gt;/// (the UI thread) is not blocked; an error is returned so the upper layer&lt;/span&gt;
&lt;span class="cd"&gt;/// can drop or degrade.&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;pump_send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ShellPumpCommand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.pump_tx&lt;/span&gt;&lt;span class="nf"&gt;.try_send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* "shell pump queue full (cap=512) …" */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a trade-off. A blocking &lt;code&gt;send&lt;/code&gt; never loses input, but it ties UI responsiveness to the network. &lt;code&gt;try_send&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The 8-entry priority queue exists for control bytes that must jump the queue, through &lt;code&gt;send_priority_interrupt&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An SSH shell is three RFC 4254 requests (pty-req, env, shell) plus window-change for resizes. &lt;/li&gt;
&lt;li&gt;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. &lt;/li&gt;
&lt;li&gt;Bound every wait, and keep all network I/O on a dedicated thread. The GUI talks to it through bounded, non-blocking queues. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next: paste. Same big write, different ways it fails — bracketed paste, and the 4095-byte line.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the SSH channel window?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why can a large paste hang an SSH session?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the SSH window-change request related to flow control?
&lt;/h3&gt;

&lt;p&gt;No. window-change (RFC 4254 §6.7) reports a new terminal size. The flow-control window is unrelated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which channel requests start an interactive SSH shell?
&lt;/h3&gt;

&lt;p&gt;pty-req, optional env requests, then shell, all defined in RFC 4254.&lt;br&gt;
  &lt;strong&gt;Related in this series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://mistlab.dev/stories/ssh-terminal-03-paste.html" rel="noopener noreferrer"&gt;What Happens When You Paste into a Terminal: Bracketed Paste, the 4095-Byte Line, and UTF-8 Boundaries&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mistlab.dev/stories/ssh-terminal-05-zmodem.html" rel="noopener noreferrer"&gt;Finding ZMODEM in a Terminal Byte Stream: How &lt;code&gt;rz&lt;/code&gt;/&lt;code&gt;sz&lt;/code&gt; Works Inside an SSH Client&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;← &lt;a href="https://mistlab.dev/stories/ssh-terminal-01-emulation.html" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; · &lt;a href="https://mistlab.dev/stories/ssh-terminal.html" rel="noopener noreferrer"&gt;Series index&lt;/a&gt; · &lt;a href="https://mistlab.dev/stories/ssh-terminal-03-paste.html" rel="noopener noreferrer"&gt;Part 3&lt;/a&gt; →&lt;/p&gt;

</description>
      <category>rust</category>
      <category>ssh</category>
      <category>linux</category>
      <category>terminal</category>
    </item>
    <item>
      <title>From Bytes to Glyphs: How a Terminal Emulator Actually Works</title>
      <dc:creator>c-wind</dc:creator>
      <pubDate>Sun, 25 Oct 2026 10:35:51 +0000</pubDate>
      <link>https://dev.to/c-wind/from-bytes-to-glyphs-how-a-terminal-emulator-actually-works-3008</link>
      <guid>https://dev.to/c-wind/from-bytes-to-glyphs-how-a-terminal-emulator-actually-works-3008</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Originally published on &lt;a href="https://mistlab.dev/stories/ssh-terminal-01-emulation.html" rel="noopener noreferrer"&gt;MistLab&lt;/a&gt;.&lt;/strong&gt; This is Part 1 of the series &lt;a href="https://mistlab.dev/stories/ssh-terminal.html" rel="noopener noreferrer"&gt;&lt;em&gt;Building an SSH Terminal&lt;/em&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;TL;DR&lt;/p&gt;

&lt;p&gt;A terminal emulator turns a byte stream from a program into pixels in three stages. A &lt;strong&gt;parser&lt;/strong&gt; (a resumable state machine) splits the stream into printable characters and control sequences defined by ECMA-48 and xterm. A &lt;strong&gt;grid&lt;/strong&gt; of character cells applies their meaning: cursor, scrolling regions, modes, wide characters, the alternate screen. A &lt;strong&gt;renderer&lt;/strong&gt; then draws the grid. MistTerm uses &lt;code&gt;alacritty_terminal&lt;/code&gt; for the parser and grid, and draws the grid itself with egui.&lt;/p&gt;

&lt;p&gt;Type &lt;code&gt;ls --color&lt;/code&gt; 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 &lt;strong&gt;parser&lt;/strong&gt; turns that stream into actions. Those actions mutate a &lt;strong&gt;grid&lt;/strong&gt; of character cells. A &lt;strong&gt;renderer&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does a program talk to a terminal? Only through a byte stream
&lt;/h2&gt;

&lt;p&gt;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):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;C0 controls&lt;/strong&gt; — single bytes below 0x20: carriage return &lt;code&gt;\r&lt;/code&gt;, line feed &lt;code&gt;\n&lt;/code&gt;, backspace &lt;code&gt;\b&lt;/code&gt;, tab &lt;code&gt;\t&lt;/code&gt;, bell &lt;code&gt;BEL&lt;/code&gt;. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escape sequences&lt;/strong&gt; — &lt;code&gt;ESC&lt;/code&gt; (0x1B) followed by one or more bytes. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSI sequences&lt;/strong&gt; — &lt;code&gt;ESC [&lt;/code&gt;, optional parameters, optional intermediates, a final byte in 0x40–0x7E. &lt;code&gt;ESC[31m&lt;/code&gt; sets the foreground to red (SGR), &lt;code&gt;ESC[2J&lt;/code&gt; erases the display, &lt;code&gt;ESC[10;5H&lt;/code&gt; moves the cursor to row 10, column 5. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OSC strings&lt;/strong&gt; — &lt;code&gt;ESC ]&lt;/code&gt; … terminated by &lt;code&gt;BEL&lt;/code&gt; or the string terminator &lt;code&gt;ESC \&lt;/code&gt;; used for window titles, hyperlinks and the like. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DEC private modes&lt;/strong&gt; — &lt;code&gt;CSI ? Pm h&lt;/code&gt; to set and &lt;code&gt;CSI ? Pm l&lt;/code&gt; 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. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;em&gt;Control Sequences&lt;/em&gt; document (ctlseqs), because most software assumes "xterm-compatible". That assumption is negotiated through &lt;code&gt;$TERM&lt;/code&gt;: when MistTerm opens a session it requests a PTY with the terminal type &lt;code&gt;xterm-256color&lt;/code&gt;, and programs on the server look that name up in the terminfo database to decide which sequences to emit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/ssh/client.rs&lt;/span&gt;
&lt;span class="n"&gt;channel&lt;/span&gt;
    &lt;span class="nf"&gt;.request_pty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"xterm-256color"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;px_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;px_h&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Stage 1: how a VT parser state machine works
&lt;/h2&gt;

&lt;p&gt;A byte stream has no message boundaries. One &lt;code&gt;read()&lt;/code&gt; may end with &lt;code&gt;ESC[3&lt;/code&gt; and the next begin with &lt;code&gt;1m&lt;/code&gt;. The parser therefore has to keep state across calls and consume input one byte at a time.&lt;/p&gt;

&lt;p&gt;The widely used model is Paul Williams' DEC-compatible parser (documented at vt100.net), 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: &lt;em&gt;print&lt;/em&gt; a character, &lt;em&gt;execute&lt;/em&gt; a C0 control, &lt;em&gt;dispatch&lt;/em&gt; a completed escape, CSI or OSC sequence. Some transitions apply "anywhere": &lt;code&gt;ESC&lt;/code&gt; always starts a new sequence, and CAN or SUB abort one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmistlab.dev%2Fassets%2Fimages%2Fssh-terminal%2Ff01-vt-parser.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmistlab.dev%2Fassets%2Fimages%2Fssh-terminal%2Ff01-vt-parser.svg" alt="A simplified VT parser state machine" width="1245" height="630"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A simplified VT parser state machine&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In Rust this layer is the &lt;code&gt;vte&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: the grid, where the semantics live
&lt;/h2&gt;

&lt;p&gt;The meaning of each action is where most of the work is. The visible screen is a &lt;code&gt;rows × columns&lt;/code&gt; 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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After &lt;code&gt;DECSTBM&lt;/code&gt; sets a scrolling region, a line feed on its bottom margin scrolls only that region. &lt;/li&gt;
&lt;li&gt;The alternate screen has no scrollback. &lt;/li&gt;
&lt;li&gt;Writing in the last column leaves the cursor in a "pending wrap" state. The wrap happens only when the &lt;em&gt;next&lt;/em&gt; printable character arrives. &lt;/li&gt;
&lt;li&gt;A double-width character occupies two cells. Overwriting either half must clear the other. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That is why MistTerm does not implement them itself. &lt;code&gt;alacritty_terminal&lt;/code&gt; packages the parser, grid and semantics from the Alacritty terminal as a library. MistTerm's adapter (&lt;code&gt;src/terminal/alacritty.rs&lt;/code&gt;) feeds it bytes in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.content_epoch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.content_epoch&lt;/span&gt;&lt;span class="nf"&gt;.wrapping_add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.parser&lt;/span&gt;&lt;span class="nf"&gt;.advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.term&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;parser&lt;/code&gt; is an &lt;code&gt;alacritty_terminal::vte::ansi::Processor&lt;/code&gt; and &lt;code&gt;term&lt;/code&gt; is a &lt;code&gt;Term&lt;/code&gt;. &lt;code&gt;advance&lt;/code&gt; consumes a whole chunk and keeps any half-finished sequence for the next call. &lt;code&gt;content_epoch&lt;/code&gt; is MistTerm's own counter. The UI compares it between frames and skips re-layout when nothing changed.&lt;/p&gt;

&lt;p&gt;Mode queries go straight to &lt;code&gt;Term&lt;/code&gt;. For example, this is how the paste code knows whether the remote program has asked for bracketed paste:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="cd"&gt;/// Whether the remote program enabled bracketed paste (DECSET 2004; bash/zsh/vim do).&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;is_bracketed_paste&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.term&lt;/span&gt;
        &lt;span class="nf"&gt;.mode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;alacritty_terminal&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;term&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;TermMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;BRACKETED_PASTE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The grid size must match the remote PTY. When the window is resized, the local &lt;code&gt;Term&lt;/code&gt; is resized and the server is told through the SSH &lt;code&gt;window-change&lt;/code&gt; channel request (RFC 4254 §6.7; &lt;code&gt;request_pty_size&lt;/code&gt; in the &lt;code&gt;ssh2&lt;/code&gt; crate). Otherwise remote programs keep wrapping at the old width.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: drawing the grid
&lt;/h2&gt;

&lt;p&gt;Alacritty itself renders glyphs with OpenGL. MistTerm is a full desktop application with side panels, an SFTP browser and dialogs, built on &lt;code&gt;egui&lt;/code&gt; (an immediate-mode GUI) with the &lt;code&gt;wgpu&lt;/code&gt; backend. So the terminal area is drawn with egui's text layout too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmistlab.dev%2Fassets%2Fimages%2Fssh-terminal%2Ff01-render.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmistlab.dev%2Fassets%2Fimages%2Fssh-terminal%2Ff01-render.svg" alt="From PTY bytes to pixels in MistTerm" width="1321" height="212"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;From PTY bytes to pixels in MistTerm&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For each visible row, cells are converted into an egui &lt;code&gt;LayoutJob&lt;/code&gt;. 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;same_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;TextFormat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;TextFormat&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="py"&gt;.color&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="py"&gt;.color&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="py"&gt;.extra_letter_spacing&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="py"&gt;.extra_letter_spacing&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="py"&gt;.line_height&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="py"&gt;.line_height&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="py"&gt;.font_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="py"&gt;.font_id&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_bg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="nf"&gt;.intersects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wide_spacer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;flush_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;run_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;run_fmt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="nf"&gt;.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u{200b}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;cell_fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;spacing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Flags&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;WIDE_CHAR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;cell_w&lt;/span&gt;&lt;span class="nf"&gt;.max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="c1"&gt;// same format as the current run → push; otherwise flush and start a new run&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details here exist because of CJK text:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Spacer cells.&lt;/strong&gt; A wide character is stored as the character itself (flagged &lt;code&gt;WIDE_CHAR&lt;/code&gt;) followed by a spacer cell (&lt;code&gt;WIDE_CHAR_SPACER&lt;/code&gt;). 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. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forcing the advance width.&lt;/strong&gt; 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 &lt;code&gt;extra_letter_spacing&lt;/code&gt; to wide characters forces each one to occupy exactly two cell widths. &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Backgrounds are handled separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Glyph layer background is always transparent; ANSI and selection backgrounds are&lt;/span&gt;
&lt;span class="c1"&gt;// painted by the UI, so egui's background expansion can't overlap adjacent lines.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;cell_fmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Color32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra_spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;TextFormat&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;font_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;FontId&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;monospace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;font_size&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Color32&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;TRANSPARENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;line_height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line_height&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;extra_letter_spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;extra_spacing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;valign&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;egui&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Align&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;BOTTOM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="nn"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is left to build
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A terminal and its program share only a byte stream. Control sequences follow ECMA-48 and xterm conventions, and &lt;code&gt;$TERM&lt;/code&gt; tells the program which dialect to speak. &lt;/li&gt;
&lt;li&gt;Parsing is a resumable state machine (Williams' model, implemented by &lt;code&gt;vte&lt;/code&gt;). &lt;/li&gt;
&lt;li&gt;The grid carries the hard semantics: margins, wrap state, modes, wide characters, alternate screen. &lt;code&gt;alacritty_terminal&lt;/code&gt; provides a mature implementation. &lt;/li&gt;
&lt;li&gt;Rendering into a general GUI needs care: merge runs, give wide characters explicit spacing, and paint backgrounds per cell. &lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the difference between the parser and the grid in a terminal emulator?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does TERM=xterm-256color do?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do CJK characters drift out of alignment in some terminals?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does vim give the screen back when it exits?
&lt;/h3&gt;

&lt;p&gt;It switches to the alternate screen with DEC private mode 1049 and switches back on exit. The alternate screen has no scrollback.&lt;br&gt;
  &lt;strong&gt;Related in this series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="//ssh-terminal-02-ssh-channel.html"&gt;SSH Flow Control, and Why an SSH Client Must Keep Reading While It Writes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="//ssh-terminal-03-paste.html"&gt;What Happens When You Paste into a Terminal: Bracketed Paste, the 4095-Byte Line, and UTF-8 Boundaries&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="//ssh-terminal.html"&gt;Series index&lt;/a&gt; · &lt;a href="//ssh-terminal-02-ssh-channel.html"&gt;Part 2&lt;/a&gt; →&lt;/p&gt;

</description>
      <category>rust</category>
      <category>terminal</category>
      <category>ssh</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
