<?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: Stone</title>
    <description>The latest articles on DEV Community by Stone (@stonebuilds).</description>
    <link>https://dev.to/stonebuilds</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%2F4195613%2F32d7d0b0-c2db-4fad-ab42-df1f77517bbd.png</url>
      <title>DEV Community: Stone</title>
      <link>https://dev.to/stonebuilds</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/stonebuilds"/>
    <language>en</language>
    <item>
      <title>Keeping a Mosh session alive when iOS suspends your app</title>
      <dc:creator>Stone</dc:creator>
      <pubDate>Tue, 20 Oct 2026 09:08:48 +0000</pubDate>
      <link>https://dev.to/stonebuilds/keeping-a-mosh-session-alive-when-ios-suspends-your-app-5237</link>
      <guid>https://dev.to/stonebuilds/keeping-a-mosh-session-alive-when-ios-suspends-your-app-5237</guid>
      <description>&lt;p&gt;Mosh is the reason a terminal on a phone is usable at all. It runs over UDP, keeps the screen state in sync instead of replaying a byte stream, and survives roaming: walk from Wi-Fi onto 5G and the session simply continues, because the server accepts packets from whatever address can prove it holds the session key.&lt;/p&gt;

&lt;p&gt;That works beautifully on a laptop. On iOS there is one more failure mode that roaming does not cover: &lt;strong&gt;the client process itself goes away.&lt;/strong&gt; Switch to Safari for a minute and iOS suspends the app; leave it longer and the system may terminate it. The server is still there, still holding your shell, but the client-side half of the protocol (sequence numbers, the last acknowledged screen state, the crypto session) lived in memory, and that memory is gone.&lt;/p&gt;

&lt;p&gt;This post is about how I handled that while building &lt;a href="https://agentsmesh.app/en/products/yoloshell" rel="noopener noreferrer"&gt;YoloShell&lt;/a&gt;, an SSH / Mosh / tmux client for iPhone and iPad.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Mosh client written in Swift
&lt;/h2&gt;

&lt;p&gt;The usual way to ship Mosh on iOS is to cross-compile the C++ reference client and its dependencies. I went the other way and vendored &lt;a href="https://github.com/wiedymi/swift-mosh" rel="noopener noreferrer"&gt;swift-mosh&lt;/a&gt; (MIT), a small pure-Swift implementation: about a dozen source files, no native dependencies. It is split into modules that map neatly onto the protocol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MoshCore         MoshClientSession — the top-level actor
MoshTransport    UDP via Network.framework (NWConnection)
MoshWire         packet / fragment format
MoshProtoLite    a protobuf-lite encoder/decoder
MoshCryptoOCB    AES-128-OCB
MoshCompression  zlib
MoshBootstrap    parses mosh-server's output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the session is a Swift actor driven by async/await and &lt;code&gt;AsyncStream&lt;/code&gt;, there is no pthread, signal, or pipe plumbing between the terminal UI and the protocol. Vendoring (rather than depending on a package) also meant I could add the one API I needed without forking: a read-only &lt;code&gt;connectivitySnapshot()&lt;/code&gt; exposing smoothed RTT, retransmit timeout and time since the last inbound packet, which drives the connection banner in the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bootstrap: SSH first, then hand off to UDP
&lt;/h2&gt;

&lt;p&gt;Mosh still needs SSH to start. The app opens an SSH connection, runs &lt;code&gt;mosh-server new …&lt;/code&gt; as an exec request, and parses the line every Mosh user has seen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MOSH CONNECT &amp;lt;port&amp;gt; &amp;lt;key&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From there the SSH connection is no longer needed. The port and key go to &lt;code&gt;MoshClientSession&lt;/code&gt;, which takes over the UDP channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Suspend: snapshot the client, not the bytes
&lt;/h2&gt;

&lt;p&gt;The key observation is that the client's protocol state is small and serializable. When the app moves to the background, every active Mosh tab is asked to suspend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;suspend&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;session&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;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makeSnapshot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;JSONEncoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snapshot&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 encoded snapshot is written atomically to the app's Application Support directory, one file per tab. The port and key are persisted alongside the tab. Nothing is sent to the server: from its point of view, the client just went quiet, which is exactly what it does when a phone goes through a tunnel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resume: three levels of fallback
&lt;/h2&gt;

&lt;p&gt;When the user comes back, possibly after the app was terminated and relaunched, each tab tries to rejoin its existing server-side session:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cheap reachability check first.&lt;/strong&gt; Mosh is UDP, so there is no handshake that tells you quickly whether the host is there. Instead, the app makes a non-blocking TCP connect to the host's SSH port with a short timeout. If that fails, the tab fails fast with a clear "host unreachable" instead of waiting on UDP retransmits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore from the snapshot.&lt;/strong&gt; If a saved snapshot exists, &lt;code&gt;MoshClientSession.restore(from:)&lt;/code&gt; rebuilds the client and starts talking to the same port with the same key. If it doesn't exist (or decoding fails), the client reconnects with just the port and key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait for proof of life, then give up gracefully.&lt;/strong&gt; The tab waits a short recovery window for the server to answer. If it does, you are back exactly where you were. If not (the server timed out, the machine rebooted), the saved state is cleared and the app starts a fresh SSH + Mosh session.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why the fresh start still lands you in the same place
&lt;/h2&gt;

&lt;p&gt;Step 3 would lose your work if the shell itself lived in the Mosh session. It usually doesn't, because YoloShell attaches to tmux on connect by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; mouse on &lt;span class="se"&gt;\;&lt;/span&gt; new-session &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;new-session -A&lt;/code&gt; means "attach if &lt;code&gt;main&lt;/code&gt; exists, otherwise create it", so even the worst-case path (a brand-new Mosh session) reattaches to the same tmux session with your panes and scrollback intact.&lt;/p&gt;

&lt;p&gt;A small detail that took a while to get right: the &lt;code&gt;set&lt;/code&gt; has to come &lt;em&gt;before&lt;/em&gt; &lt;code&gt;new-session&lt;/code&gt; in the same tmux command, because once tmux attaches, it blocks everything after it. At that point the target session may not exist yet, so the option can't be set per-session and has to be global (&lt;code&gt;-g&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that surprised me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The iOS Simulator is not a good network lab.&lt;/strong&gt; A loopback &lt;code&gt;NWConnection&lt;/code&gt; test that passes on a device hangs in the Simulator, and some of the upstream session tests hang there for minutes. Those are skipped or excluded in the Simulator with the reason written down, while the crypto, wire format, compression and bootstrap suites run everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failing fast matters more than reconnecting fast.&lt;/strong&gt; Most "my terminal is frozen" moments came from a client patiently retransmitting to a host that was not there. The TCP probe in front of the UDP resume removed almost all of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;YoloShell is on the &lt;a href="https://apps.apple.com/us/app/yoloshell-ssh-terminal-vnc-rdp/id6771729249" rel="noopener noreferrer"&gt;App Store&lt;/a&gt; for iPhone and iPad. SSH keys and connection settings stay on the device, and there is no account. If you live in tmux on remote machines, or run coding agents over SSH, I'd love to hear what breaks for you.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>networking</category>
      <category>terminal</category>
    </item>
  </channel>
</rss>
