Skip to content
C.W.K.
Stream
Lesson 05 of 06 · published

WebSockets: Reconnect the Connection, Never the Command

~17 min · engine-at-home, websockets, reconnect, heartbeat, urlsession

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"A socket that is dead looks exactly like a socket that is quiet."

When Request and Reply Are Not Enough

Some clients need frames flowing both ways for as long as a screen is open: the terminal app's phone client attached to a live session, the coding engine's remote gateway, the bridges into Pippa's backend. URLSessionWebSocketTask covers the protocol. send(.string(…)) writes a text frame and send(.data(…)) a binary one, and against a Python websockets server the two arrived exactly that way. That difference matters to the peer: the family's Python bridges read JSON from text frames while the terminal gateway wants binary, so the shared connection sends text unless told otherwise.

Three Things That Are Easy to Get Wrong

The shared connection type was composed from three apps that had each solved reconnection, at three levels of completeness. The survey named what separated them.

  1. Generation. Every teardown mints a new token, and every asynchronous callback compares against it before touching state. Without it, a receive callback from a socket you cancelled two reconnects ago can deliver a stale frame, or report a failure that tears down the socket that replaced it.
  2. Wanted is not connected, and neither is active. "The user asked to be connected", "the app is in the foreground" and "a socket is open" are three booleans. Backgrounding drops the socket without forgetting the user wants it, and a failure retries only while the first two hold.
  3. Dead looks quiet. For this lesson a test server was frozen with SIGSTOP mid-session. Five seconds later the client task still reported .running. A ping with a three-second deadline reported silence. One of the three donor apps had no heartbeat at all, so a dropped tailnet link left it "running" until something tried to write.

Retry the Connection, Never the Command

When the socket drops, the connection reconnects on its own: one second, then doubling to a thirty-second cap, forever by default, because a Mac at home that is merely asleep should be waited for. What it never does is resend a frame that was in flight. A command sent to a terminal might have run before the drop, and replaying it could run it twice, so a lost request is the app's to re-issue or to drop. Two smaller rules come from the same type. The destination is committed only by an explicit connect(to:), so editing the host field in Settings cannot redirect a live connection's next reconnect. And subscribe to the connection's events before connecting: phases are state and are replayed to a new subscriber, frames are not, so a late subscriber can miss the first ones.

Code

A ping with a deadline, and the reconnect delay·swift
import Foundation

enum PingOutcome: Equatable, Sendable {
    case alive
    case silent
    case failed(String)
}

/// Resumes exactly once: whichever of the pong and the deadline arrives first wins.
final class OnceGate: @unchecked Sendable {
    private let lock = NSLock()
    private var done = false
    func claim() -> Bool { lock.withLock { if done { return false }; done = true; return true } }
}

/// A dead socket looks exactly like a quiet one. Only a ping with a deadline tells them apart.
/// Nothing here waits on the pong handler being called, so a handler that never fires cannot hang it.
func ping(_ task: URLSessionWebSocketTask, within deadline: Duration) async -> PingOutcome {
    let gate = OnceGate()
    return await withCheckedContinuation { continuation in
        task.sendPing { error in
            guard gate.claim() else { return }
            continuation.resume(returning: error.map { .failed($0.localizedDescription) } ?? .alive)
        }
        Task {
            try? await Task.sleep(for: deadline)
            guard gate.claim() else { return }
            continuation.resume(returning: .silent)
            task.cancel(with: .goingAway, reason: nil)      // silence is the answer: tear down and reconnect
        }
    }
}

/// 1 s, 2 s, 4 s … capped at 30 s, with the exponent capped so the shift cannot overflow.
func reconnectDelay(afterFailures failures: Int) -> Duration {
    guard failures > 0 else { return .zero }
    let seconds = min(30, 1 << min(failures - 1, 5))
    return .seconds(seconds)
}
Measured against a local Python websockets server·text
reply: string("echo text: {\"hello\":1}")      # .string arrived as a text frame
reply: string("echo binary: 3")               # .data arrived as a binary frame
ping while healthy: alive
state after 5 s of server silence: running    # the server process was frozen with SIGSTOP
ping while server frozen: silent
delays: [0.0, 1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 30.0, 30.0] seconds

External links

Exercise

Start a local echo server with Python's websockets package, connect from a Swift executable, and reproduce the measurement: one text frame, one binary frame, a healthy ping, then kill -STOP the server and record the task state and the ping outcome. Then add a generation counter to your client: increment it on every teardown, capture it in the receive loop, and prove with a test that a callback from an old generation changes nothing.
Hint
kill -CONT resumes the frozen server, which is useful for watching a reconnect succeed. For the generation test you do not need a network: model the receive callback as a function that takes a generation and a frame, and call it with a stale generation.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.