Back to the dev blog Engineering update

Three ways to freeze a Mac app — and how Desk 0.9.0 fixes all three

SharkTTY Desk 0.9.0 is out today — What's New has the user-facing rundown, and you can download it here. The feature we're leading with there is guest share links: invite someone onto your Mac with a link or a QR code instead of an account. This post tells a different story — the one behind the scenes, about a hunt for something far less demo-friendly than a new feature: the whole app freezing solid, spinning beachball and all, for reasons that took real work to run down. Three independent root causes, three separate fixes, all landing in this build.

Cause one: a blocking send on the UI thread

When you're viewing another Mac through Desk, video and input travel over a WebRTC data channel, and the code that sent on that channel ran synchronously — on the UI thread. Under ordinary conditions that's invisible: the send returns essentially instantly. But WebRTC's SCTP transport keeps a fixed send buffer, 128 KiB, and if the other side falls behind and that buffer fills — backpressure — the send can block. Indefinitely. And when the call blocking is on the UI thread, that's not a stall in a video stream, that's the whole app: menus won't open, windows won't redraw, the wheel spins until something upstream relents. The WebSocket and QUIC transport paths never had this problem — they don't route sends through the same blocking call.

The fix: sends are non-blocking now. The lesson we took is a plain one: “it always returns fast enough” is an assumption, not a guarantee, and a UI thread should never be the one testing it. Any call whose worst case is “blocks until a remote peer drains a buffer” has no business running synchronously in the render loop, no matter how rare that worst case felt in practice.

Cause two: a pipe nobody was draining

The second cause is an old, well-known Unix trap that's easy to reintroduce without noticing. A subprocess's output pipe has a small kernel buffer — about 64 KB — and if nothing reads from it, the buffer fills, the child process blocks trying to write more, and anything waiting on that child to exit blocks too: waitUntilExit never returns, because the process it's waiting for is stuck writing to a pipe nobody's listening to.

One call fitting this exact shape ran on the main thread every 1.5 seconds whenever the lock-screen capture helper was enabled — a periodic check that, once its target filled its pipe, could wedge the entire app on a clock, not just once but repeatedly. That's what made this one so disruptive: not a rare edge case, but a recurring timer landing directly on a deadlock.

The fix has two parts: output we have no intention of reading now goes to the null device instead of a pipe, and output we do consume gets drained before we wait on the process. Lesson: a pipe you create is a contract to read it — if you don't plan to, don't create one; if you do, drain it before you block on the thing writing to it.

Cause three: history that got slower the longer you worked

The third wasn't a hang so much as a slow bleed: long mosh terminal sessions synced fine at first, then measurably slower the longer they ran. The cause was algorithmic — the cost of keeping terminal history in sync scaled quadratically with how much history there was, O(H²). Not a lock, not a leak, just an approach that was fine for a five-minute session and increasingly expensive for a five-hour one, until it looked, from the outside, like the same kind of freeze the other two caused.

The fix restructures history to use structural sharing, so long-running sessions stay fast instead of slowing down as they age. Lesson: quadratic cost is invisible in every quick test and every demo, because it only shows up once a session has run long enough for H to matter — which means the people who find it are always your actual users, on their actual longest sessions, unless you go looking for it on purpose.

Share links, now in https form

The feature 0.9.0 leads with is guest share links: from the Share panel on your Mac, you can invite someone in with a link or a QR code, with the host controlling expiry, how many people can be viewing at once, whether it's view-only, and an optional allow-list — and every share can be revoked at any time. The link itself never carries your Mac's permanent connection key: guests redeem short-lived tickets from the cloud, and it's the agent on your Mac, not the cloud, that verifies and enforces expiry, concurrency limits, view-only mode, and revocation. There's also an early, payment-free option to offer a share as an hourly exclusive booking, where a guest picks a free UTC-aligned hour slot and gets your Mac to themselves for that window.

What 0.9.0 changes is the shape of the link itself: it's now an https URL — https://safafish.com/s/#s=<code>&n=<name> — instead of a raw ttyshare:// address. An https link opens in any browser, on any device, to a hand-off page that offers “Open in SharkTTY,” points to the App Store if it isn't installed yet, and shows a QR code to scan from an iPhone or iPad. Paste the https link back into the app on a device that already has it, and that round-trips too. To be precise about what this isn't yet: tapping the link doesn't instantly open the iOS app on its own — that hand-off depends on an app build we haven't shipped. What you get today is a link that opens in any browser to a page that hands the link off to SharkTTY, which is already a much friendlier thing to paste into a chat than a URI scheme half your guests' devices won't recognize.

Also in 0.9.0

Two smaller touches worth a mention. In Desk's terminals — SSH, local shell, and mosh alike — holding Option and clicking now moves the shell cursor to that spot, the same trick Terminal.app and iTerm2 play, via synthesized arrow keys; it works in the primary buffer and in full-screen apps on the alt screen too. And the sharp magnifier had a small accuracy bug: a throttling window could discard the final move of a drag, so the loupe sometimes settled slightly behind wherever your cursor actually stopped. The resting position now always gets its update, so the magnified content lands exactly under the crosshair.

Questions or bug reports for anything above? The feedback board is the fastest way to reach us.