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.
That works beautifully on a laptop. On iOS there is one more failure mode that roaming does not cover: the client process itself goes away. 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.
This post is about how I handled that while building YoloShell, an SSH / Mosh / tmux client for iPhone and iPad.
A Mosh client written in Swift
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 swift-mosh (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:
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
Because the session is a Swift actor driven by async/await and AsyncStream, 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 connectivitySnapshot() exposing smoothed RTT, retransmit timeout and time since the last inbound packet, which drives the connection banner in the UI.
Bootstrap: SSH first, then hand off to UDP
Mosh still needs SSH to start. The app opens an SSH connection, runs mosh-server new … as an exec request, and parses the line every Mosh user has seen:
MOSH CONNECT <port> <key>
From there the SSH connection is no longer needed. The port and key go to MoshClientSession, which takes over the UDP channel.
Suspend: snapshot the client, not the bytes
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:
public func suspend() async -> Data? {
guard let session else { return nil }
let snapshot = try? await session.makeSnapshot()
await session.stop()
...
return try? JSONEncoder().encode(snapshot)
}
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.
Resume: three levels of fallback
When the user comes back, possibly after the app was terminated and relaunched, each tab tries to rejoin its existing server-side session:
- Cheap reachability check first. 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.
-
Restore from the snapshot. If a saved snapshot exists,
MoshClientSession.restore(from:)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. - Wait for proof of life, then give up gracefully. 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.
Why the fresh start still lands you in the same place
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:
tmux set -g mouse on \; new-session -A -s main
new-session -A means "attach if main 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.
A small detail that took a while to get right: the set has to come before new-session 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 (-g).
Things that surprised me
-
The iOS Simulator is not a good network lab. A loopback
NWConnectiontest 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. - Failing fast matters more than reconnecting fast. 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.
Try it
YoloShell is on the App Store 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.










