<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Nexus Shell Engineering]]></title><description><![CDATA[Engineering notes from building Nexus Shell, a native macOS SSH client. Terminal performance, SSH and SFTP workflows, server monitoring, security, SwiftUI, and ]]></description><link>https://nexusshell.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Nexus Shell Engineering</title><link>https://nexusshell.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 15 Aug 2026 07:10:26 GMT</lastBuildDate><atom:link href="https://nexusshell.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Keeping a macOS Terminal UI Responsive During Large SSH Output Bursts]]></title><description><![CDATA[An SSH terminal can feel fast when you type one command at a time and still fall apart when a process prints several megabytes of output. The network is rarely the only bottleneck. In a native macOS a]]></description><link>https://nexusshell.hashnode.dev/keeping-a-macos-terminal-ui-responsive-during-large-ssh-output-bursts</link><guid isPermaLink="true">https://nexusshell.hashnode.dev/keeping-a-macos-terminal-ui-responsive-during-large-ssh-output-bursts</guid><category><![CDATA[performance]]></category><category><![CDATA[macOS]]></category><category><![CDATA[Swift]]></category><category><![CDATA[ssh]]></category><dc:creator><![CDATA[Nexus Shell]]></dc:creator><pubDate>Fri, 07 Aug 2026 14:34:34 GMT</pubDate><content:encoded><![CDATA[<p>An SSH terminal can feel fast when you type one command at a time and still fall apart when a process prints several megabytes of output. The network is rarely the only bottleneck. In a native macOS app with a WebKit-backed terminal, every chunk can cross several boundaries:</p>
<ol>
<li>the SSH transport reads bytes;</li>
<li>the app updates session state and logs;</li>
<li>Swift posts the output to the UI;</li>
<li>WebKit IPC delivers it to the terminal renderer;</li>
<li>the renderer parses escape sequences and paints.</li>
</ol>
<p>If every small read immediately triggers that entire path, a fast server can create hundreds of main-thread updates per second. CPU usage rises, scrolling becomes uneven, and input latency gets worse precisely when output is busiest.</p>
<p>Here is the design that worked for the terminal pipeline in Nexus Shell.</p>
<h2>Drain the transport, but keep control responsive</h2>
<p>With libssh2, socket readability does not mean its internal decrypted buffer is empty. The read loop should continue until <code>EAGAIN</code>, otherwise already-buffered output can be left behind while the thread goes back to sleep.</p>
<p>At the same time, an unlimited drain loop can starve input, resize, cancellation, and keepalive work. I cap each turn at 64 reads of 64 KiB, then return to the command inbox before continuing:</p>
<pre><code class="language-swift">var batch = 0

while batch &lt; 64 {
    let count = libssh2_channel_read_ex(channel, 0, buffer, 65_536)

    if count &gt; 0 {
        batch += 1
        consume(Data(bytes: buffer, count: count))
    } else if count == LIBSSH2_ERROR_EAGAIN {
        break
    } else {
        handleEOFOrError(count)
        break
    }
}

if batch &gt;= 64 {
    processPendingCommands()
}
</code></pre>
<p>The exact limit is not universal. The useful principle is to drain enough data to avoid unnecessary sleeps while preserving a bounded opportunity for control messages.</p>
<h2>Coalesce output at the UI boundary</h2>
<p>The next bottleneck is not the SSH read itself. It is the repeated work caused by each chunk: state changes, notifications, WebKit calls, terminal parsing, and rendering.</p>
<p>Instead of forwarding every chunk independently, append output per session and flush at roughly one display frame:</p>
<pre><code class="language-swift">@MainActor
func enqueue(_ chunk: String, sessionID: UUID) {
    pending[sessionID, default: ""].append(chunk)

    guard !flushScheduled else { return }

    let elapsed = CACurrentMediaTime() - lastFlush
    if elapsed &gt;= 0.016 {
        flush()
    } else {
        flushScheduled = true
        scheduleFlush(after: 0.016 - elapsed)
    }
}
</code></pre>
<p>This changes the cost model from “one UI transaction per transport callback” to “at most about one transaction per frame per active session.” Large bursts become much cheaper without dropping data.</p>
<h2>Preserve zero-latency echo for sparse output</h2>
<p>Always waiting 16 ms makes benchmarks look tidy but makes typing feel slightly soft. The compromise is a leading-edge flush:</p>
<ul>
<li>if enough time has passed since the previous flush, send the first chunk immediately;</li>
<li>only coalesce subsequent chunks that arrive within the same frame window.</li>
</ul>
<p>Interactive echo stays immediate, while <code>cat</code>-style output is batched.</p>
<h2>Keep binary protocols out of the text decoder</h2>
<p>Terminal output is usually UTF-8 text, but in-terminal transfer protocols such as ZMODEM contain arbitrary bytes. Decoding those bytes into <code>String</code> can replace invalid sequences and corrupt checksums.</p>
<p>The transport therefore has two mutually exclusive paths:</p>
<ul>
<li>normal terminal mode emits decoded text;</li>
<li>raw transfer mode emits <code>Data</code> unchanged.</li>
</ul>
<p>Switching to raw mode only after the transfer detector has matched preserves terminal behavior without treating a binary stream as text.</p>
<h2>Buffer output before the UI handler is attached</h2>
<p>One subtle race happens at startup: the SSH owner thread may receive the server banner or first shell prompt before the terminal view installs its output handler. Dropping that output makes a connection appear blank or incomplete.</p>
<p>I keep a small bounded pending buffer next to the handler. Installing the handler atomically drains the pending text. The bound matters because a view that never attaches should not create unbounded memory growth.</p>
<h2>What to measure</h2>
<p>Throughput alone is not enough. A useful test matrix includes:</p>
<ul>
<li>time until the first character is visible;</li>
<li>typing latency during a large output burst;</li>
<li>CPU usage in both the app and WebKit process;</li>
<li>resize and cancellation responsiveness;</li>
<li>exact preservation of multilingual UTF-8 output;</li>
<li>binary transfer integrity;</li>
<li>the initial banner and prompt before the view fully mounts.</li>
</ul>
<p>The broader lesson is that terminal performance is a scheduling problem across boundaries. Drain the transport correctly, batch expensive UI work, keep sparse interaction immediate, and never mix binary and text paths.</p>
<p>This article comes from work on <a href="https://nexusshell.app/">Nexus Shell</a>, the native macOS SSH client I develop. The product link is included for context; the architecture above is useful for any terminal UI that bridges a native transport and a web-based renderer.</p>
<p>Suggested tags: <code>macos</code>, <code>swift</code>, <code>ssh</code>, <code>performance</code></p>
]]></content:encoded></item></channel></rss>