Test iOS WebSocket heartbeats and isolate connection generations

Test iOS WebSocket heartbeats and isolate connection generations

Test iOS WebSocket heartbeats and isolate connection generations

The chat screen is back online, only for a delayed close callback to push it back into “Connecting.” Problems like this usually are not caused by too few reconnection attempts. They happen because an old connection can still modify the new session. When validating an iOS WebSocket client on a GNUMac cloud Mac, separate heartbeat deadlines and connection identity from the networking code before testing the real transport. This makes boundary cases easier to reproduce than repeatedly disconnecting the network.

Define connection identity and timing rules first

Assign an increasing generation number whenever you create a transport task. Connection-open, Pong, receive-failure, and close callbacks must carry the number captured when the task was created, rather than read the “current number” when they execute. Otherwise, a stale callback can be mistaken for an event from the new connection.

Use a monotonic clock, not the system date. In production, you can calculate elapsed time with ContinuousClock; in tests, pass integer milliseconds directly. This article uses an explicit policy: wait one interval after the connection opens before sending a Ping, send no second Ping while waiting for a Pong, and close the connection if no response has arrived by the deadline.

Canceling an old task is not an isolation barrier. Callbacks that are already queued can still run, so the event receiver must check both the connection number and the current phase.

This model handles only the heartbeats and identity of a single connection. It excludes reconnection backoff, message replay, and application-level acknowledgments, so that several protocols do not become one difficult-to-validate system.

Write a network-independent state machine

Save the following as heartbeat.swift. tick returns the action to perform; an outer adapter is responsible for actually sending the Ping or canceling the transport task. The short intervals in this example are for testing only, not recommended production settings.

struct Heartbeat {
    enum Phase {
        case idle, connecting, open
    }

    enum Action: Equatable {
        case ping(Int)
        case close(Int)
    }

    private(set) var generation = 0
    private(set) var phase = Phase.idle
    private var nextPing = 0
    private var deadline: Int?
    let interval: Int
    let timeout: Int

    mutating func connect() -> Int {
        generation += 1
        phase = .connecting
        deadline = nil
        return generation
    }

    mutating func opened(_ id: Int, now: Int) {
        guard id == generation, phase == .connecting else {
            return
        }
        phase = .open
        nextPing = now + interval
    }

    mutating func pong(_ id: Int, now: Int) {
        guard id == generation, phase == .open,
              let limit = deadline, now < limit else {
            return
        }
        deadline = nil
        nextPing = now + interval
    }

    mutating func tick(now: Int) -> Action? {
        guard phase == .open else { return nil }
        if let limit = deadline {
            guard now >= limit else { return nil }
            phase = .idle
            deadline = nil
            return .close(generation)
        }
        guard now >= nextPing else { return nil }
        deadline = now + timeout
        return .ping(generation)
    }
}

By convention, a Pong received exactly at the deadline is also late. This ensures that processing the timer or the response first at the same instant does not change the timeout outcome. The actual close action is still triggered by the next tick, so the scheduler must keep driving these checks.

Cover reconnection boundaries with explicit timestamps

Append the following assertions and run xcrun swift heartbeat.swift in its default, unoptimized mode. A failed assertion makes the process exit with a nonzero status; if all assertions pass, there is no output. Do not use build settings that disable these checks.

var h = Heartbeat(interval: 100, timeout: 1000)
let first = h.connect()
h.opened(first, now: 0)

precondition(h.tick(now: 99) == nil)
precondition(h.tick(now: 100) == .ping(first))
precondition(h.tick(now: 1099) == nil)
h.pong(first, now: 1100)
precondition(h.tick(now: 1100) == .close(first))

let second = h.connect()
h.opened(first, now: 1200)
precondition(h.phase == .connecting)
h.opened(second, now: 2000)
precondition(h.tick(now: 2100) == .ping(second))

h.pong(first, now: 2150)
precondition(h.tick(now: 3100) == .close(second))

let third = h.connect()
h.opened(third, now: 4000)
precondition(h.tick(now: 4100) == .ping(third))
h.pong(third, now: 4200)
precondition(h.tick(now: 4299) == nil)
precondition(h.tick(now: 4300) == .ping(third))

The second sequence deliberately delivers the old connection’s Pong while the new connection is waiting for a response. Without the generation check, that Pong would incorrectly extend the new connection’s lifetime. Also add cases for repeated open events, Pongs received while idle, and repeated checks after a timeout, confirming that each failure produces only one close action.

Keep transport integration serialized

In a real client, an actor can own the state machine. Timer events, connection callbacks, and cancellation requests must all enter the same isolation domain; multiple queues must not modify the state directly. Check the number carried by each action as well: if a new connection has been created before an action executes, an old close action must not cancel the current task.

When using URLSessionWebSocketTask.sendPing, submit a Pong event to the model only if the completion callback reports no error. On error, use a failure-handling path that checks the generation. An incoming application message is not a Pong and must not automatically substitute for a transport heartbeat.

To preserve the model’s assumption of a single outstanding Ping, the adapter must ensure that each Ping submits exactly one completion event. If you switch to a wrapper that can invoke its callback more than once, add a Ping request identifier. Connection generations isolate different connections, but cannot distinguish multiple heartbeats within the same connection.

Finish with real-connection validation

Once the offline assertions pass, verify protocol behavior against a controllable test endpoint. Record at least four outcomes: a normal response, a delayed response, a direct close, and a connection-establishment failure. Also confirm that the receive loop keeps reading subsequent messages. Log only the generation, event type, monotonic time, and state transitions—not tokens or application message bodies.

Timer callbacks are not guaranteed to keep running after the app enters the background, so do not mistake the heartbeat model for a background keepalive mechanism. On returning to the foreground, reassess the connection state before deciding whether to reuse it or create a new connection. The simulator can verify event routing, but tests on a real device must still cover foreground resumption and network changes.

Successful validation does not mean “it eventually connected.” It means old events cannot modify a new session, timeout boundaries are unambiguous, and every close has a corresponding cause. Add these assertions to the pipeline so that future heartbeat-interval changes do not require another round of manual waiting to uncover race conditions.

Frequently asked questions

Do heartbeat tests require a real network outage?

Not at the unit level. Inject monotonic time and test immediately before and at the deadline. Validate real Ping/Pong exchanges, cancellation, and network changes separately in integration tests.

Why check connection generations after cancelling the old task?

Cancellation cannot retract callbacks already queued for execution. Increment a generation counter for each connection and check both that counter and the current state before accepting a callback.

Configure a dedicated Mac mini for your workflow

Choose a model, region, and rental term to match your project. Check memory, storage, and connectivity needs before ordering; availability is confirmed in real time by the console.

Start with your workflow, then get your Mac up and running.

A dedicated physical Mac mini. Choose a model, region, and rental term, then configure storage to suit your project.