Skip to content
macOSDeep Dive Published Updated 10 min readViews unavailable

URLSessionWebSocketTask on macOS: Message Loops, Ping, and Close Semantics

Build a macOS URLSessionWebSocketTask client with bounded receives, an owned message loop, verified pong handling, and explicit close semantics.

URLSessionWebSocketTask brings WebSocket messages into the same session lifecycle as other Foundation transfers. The task performs the HTTP handshake, then sends and receives WebSocket text or binary messages over the connection. Its convenience does not remove the application’s protocol responsibilities: the task does not know whether a message is a valid request, whether it belongs to the right account, or whether the peer has processed a command.

The useful design unit is a session-owned task with one clear owner for receive operations, an explicit message-size ceiling, and a close path that says what the client intends. That keeps a long-lived WebSocket from becoming an unbounded buffer or an ambiguous collection of concurrent readers and cancellation callbacks.

Create the task, then resume it

Create a task from a ws: or wss: URL using a URLSession. If the server negotiates a subprotocol, pass the client’s supported protocol names when making the task. Like other URLSessionTask instances, a WebSocket task is created suspended; call resume() to begin it. Foundation can queue reads and writes requested before the handshake completes and perform them after the handshake.

let session = URLSession(configuration: .ephemeral)
let endpoint = URL(string: "wss://example.invalid/socket")!
let task = session.webSocketTask(with: endpoint, protocols: ["events.v1"])
task.maximumMessageSize = 64 * 1024
task.resume()

The URL is not a ready connection. Observe the task’s completion and, when useful, implement URLSessionWebSocketDelegate to observe handshake negotiation and close frames. The delegate’s didOpenWithProtocol callback reports a successful handshake and the selected protocol. Its close callback reports a close frame received from the server; do not treat it as a callback for every local cancellation path.

Receive one complete message at a time

receive completes when the frames for one WebSocket message are available. Its result is URLSessionWebSocketTask.Message, which is either .string(String) or .data(Data). A text message is UTF-8 encoded on the wire; binary payloads remain Data. The method is not a byte-stream API where the caller must reconstruct WebSocket framing, but your application still needs its own schema, ordering, authorization, and message-validation rules.

Keep a single receive loop responsible for consuming messages and making the next receive call while the connection remains active. This is a sound ownership policy for application code: it prevents unrelated components from competing to consume the next message and makes cancellation and shutdown easier to reason about. Branch on the enum rather than assuming every peer response is text:

func receiveNext(from task: URLSessionWebSocketTask) {
    task.receive { result in
        switch result {
        case .success(.string(let text)):
            handleText(text)
            receiveNext(from: task)
        case .success(.data(let data)):
            handleBinary(data)
            receiveNext(from: task)
        case .failure(let error):
            reportReceiveFailure(error)
        }
    }
}

The loop should stop when the owning component is shutting down or the task reports a terminal error. Do not recursively restart after every failure: a rejected handshake, server close, or network loss is not an instruction to spin. If reconnection is part of your application protocol, make it an explicit state machine with backoff, authentication refresh, and idempotency rules rather than an implicit receive-loop retry.

maximumMessageSize bounds the number of bytes Foundation buffers before a receive call fails; its count includes continuation-frame bytes. Set it to an application-appropriate maximum before resuming the task. This is a receive-side limit, not a complete bandwidth budget, queue limit, or validation of decoded content. Apply a second semantic size limit after decoding if the protocol has fields or decompressed data that can expand beyond the wire payload.

Distinguish ping from an application heartbeat

sendPing(pongReceiveHandler:) sends a WebSocket ping frame. Foundation calls the handler when the server’s pong arrives; the error is nil when no connection problem was reported, and an error describes a lost connection or other problem. When a client sends several pings, Foundation calls their handlers in send order.

A successful ping/pong is transport-level evidence that a peer endpoint responded to the control frame. It does not prove that the server has authorized a user, committed a previous application request, or is making progress on a job. If the product needs an application heartbeat, define a normal message with an explicit sequence, timeout, and reply contract in addition to WebSocket ping.

task.sendPing { error in
    if let error {
        reportConnectionProblem(error)
    } else {
        recordPongReceived()
    }
}

Avoid overlapping unbounded ping timers with automatic reconnect logic. Keep one component responsible for the timer and cancel it when the task is closing. The framework callback reports a transport error; retry policy, connection identity, and replay behavior remain application decisions.

Close intentionally and interpret close state carefully

cancel(with:reason:) sends a close frame with the supplied CloseCode and optional Data reason. Apple documents plain cancel() as sending a cancellation frame without a close code or reason, so it is not an equivalent spelling for an explicit normal-close request. The close reason is endpoint-defined; it is not a universal text diagnostic. Keep it short, avoid putting secrets in it, and never parse arbitrary reason bytes as trusted control data.

closeCode and closeReason can be queried while the task is running. Before the task has closed, Apple documents the close code as .invalid; that value does not itself mean the peer violated the protocol. A delegate close callback specifically tells you the task received a close frame from the server and includes its optional code and reason. Record task state, the local close request, the remote close event, and any receive/send error as separate observations.

The lifecycle matters for the session too. A session coordinates its tasks and retains its delegate until it is invalidated. Use finishTasksAndInvalidate() when outstanding tasks should be allowed to finish; invalidateAndCancel() cancels outstanding work. Choose based on shutdown intent instead of calling both indiscriminately. After requesting a normal close, give the task and session a chance to complete if the application needs to observe the peer’s response; if the app is exiting under a deadline, make that deadline explicit and report that the close was requested rather than asserting that the peer acknowledged it.

Exercise the message, pong, and close path on loopback

The following standard-library Python fixture is intentionally limited: it binds only to 127.0.0.1, accepts one client, bounds handshake headers to 16 KiB and frames to 4 KiB, rejects extensions, unmasked client frames, and fragmentation, then echoes short unfragmented text or binary messages, answers pings, and echoes a close frame. The limits prevent a local test peer from making the example allocate based on an unbounded declared length; the fixture still is not designed for hostile local users or production. Pair it with the Swift client below and do not replace the loopback URL with a public echo service when validating these local assertions.

# ws_echo.py - a tiny fixture for short frames only
import base64
import hashlib
import socket
import struct

GUID = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
MAX_HEADER_BYTES = 16 * 1024
MAX_FRAME_BYTES = 4096

def read_exact(conn, length):
    data = bytearray()
    while len(data) < length:
        chunk = conn.recv(length - len(data))
        if not chunk:
            raise EOFError("peer closed")
        data.extend(chunk)
    return bytes(data)

def read_frame(conn):
    first, second = read_exact(conn, 2)
    if not (first & 0x80):
        raise ValueError("fragmented frames are outside this fixture")
    if first & 0x70:
        raise ValueError("extension bits are outside this fixture")
    opcode = first & 0x0F
    if opcode not in (1, 2, 8, 9, 10):
        raise ValueError("unsupported frame opcode")
    length = second & 0x7F
    if length == 126:
        length = struct.unpack("!H", read_exact(conn, 2))[0]
    elif length == 127:
        length = struct.unpack("!Q", read_exact(conn, 8))[0]
    if length > MAX_FRAME_BYTES:
        raise ValueError("frame exceeds fixture limit")
    if opcode >= 8 and length > 125:
        raise ValueError("control frame exceeds protocol limit")
    if not (second & 0x80):
        raise ValueError("client-to-server frames must be masked")
    mask = read_exact(conn, 4)
    payload = read_exact(conn, length)
    if mask:
        payload = bytes(value ^ mask[i % 4] for i, value in enumerate(payload))
    return opcode, payload

def write_frame(conn, opcode, payload):
    size = len(payload)
    if size < 126:
        header = bytes((0x80 | opcode, size))
    elif size <= 0xFFFF:
        header = bytes((0x80 | opcode, 126)) + struct.pack("!H", size)
    else:
        header = bytes((0x80 | opcode, 127)) + struct.pack("!Q", size)
    conn.sendall(header + payload)

with socket.socket() as server:
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(("127.0.0.1", 8765))
    server.listen(1)
    print("LISTENING", flush=True)
    conn, _ = server.accept()
    with conn:
        conn.settimeout(10)
        request = bytearray()
        while b"\r\n\r\n" not in request:
            if len(request) >= MAX_HEADER_BYTES:
                raise ValueError("handshake headers exceed fixture limit")
            request.extend(read_exact(conn, 1))
        headers = {}
        for line in bytes(request).split(b"\r\n")[1:]:
            if b":" in line:
                name, value = line.split(b":", 1)
                headers[name.strip().lower()] = value.strip()
        if headers.get(b"upgrade", b"").lower() != b"websocket":
            raise ValueError("expected a WebSocket upgrade")
        if headers.get(b"sec-websocket-version") != b"13":
            raise ValueError("expected WebSocket version 13")
        key = headers.get(b"sec-websocket-key")
        if key is None:
            raise ValueError("missing Sec-WebSocket-Key")
        accept = base64.b64encode(hashlib.sha1(key + GUID).digest())
        conn.sendall(
            b"HTTP/1.1 101 Switching Protocols\r\n"
            b"Upgrade: websocket\r\nConnection: Upgrade\r\n"
            b"Sec-WebSocket-Accept: " + accept + b"\r\n\r\n"
        )
        while True:
            opcode, payload = read_frame(conn)
            if opcode in (1, 2):
                write_frame(conn, opcode, payload)
            elif opcode == 9:
                write_frame(conn, 10, payload)
            elif opcode == 8:
                write_frame(conn, 8, payload)
                print("CLOSE", flush=True)
                break

This Swift client uses the async send and receive methods available in current Foundation SDKs. It checks a complete text-message round trip, waits for the ping callback, requests a normal close, and waits for the server’s close callback before finishing the ephemeral session. The sample assumes a modern macOS deployment target; use the completion-handler overloads if an older toolchain or deployment target requires them.

import Foundation

final class CloseObserver: NSObject, URLSessionWebSocketDelegate, @unchecked Sendable {
    private let closeEvents = AsyncStream<URLSessionWebSocketTask.CloseCode>.makeStream(
        bufferingPolicy: .bufferingNewest(1)
    )

    var closeCodes: AsyncStream<URLSessionWebSocketTask.CloseCode> {
        closeEvents.stream
    }

    func urlSession(_ session: URLSession,
                    webSocketTask: URLSessionWebSocketTask,
                    didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
                    reason: Data?) {
        closeEvents.continuation.yield(closeCode)
    }

    func urlSession(_ session: URLSession,
                    task: URLSessionTask,
                    didCompleteWithError error: Error?) {
        closeEvents.continuation.finish()
    }
}

@main
struct WebSocketLoopbackCheck {
    static func main() async throws {
        let delegate = CloseObserver()
        let session = URLSession(configuration: .ephemeral,
                                 delegate: delegate,
                                 delegateQueue: nil)
        guard let url = URL(string: "ws://127.0.0.1:8765/echo") else {
            fatalError("invalid loopback URL")
        }
        let task = session.webSocketTask(with: url)
        task.maximumMessageSize = 64 * 1024
        task.resume()

        do {
            try await task.send(.string("loopback-message"))
            let reply = try await task.receive()
            guard case .string("loopback-message") = reply else {
                throw NSError(domain: "WebSocketLoopbackCheck", code: 1)
            }
            try await withCheckedThrowingContinuation {
                (continuation: CheckedContinuation<Void, Error>) in
                task.sendPing { error in
                    if let error {
                        continuation.resume(throwing: error)
                    } else {
                        continuation.resume(returning: ())
                    }
                }
            }
            task.cancel(with: .normalClosure, reason: nil)
            let closeCode = await withTaskGroup(
                of: URLSessionWebSocketTask.CloseCode?.self,
                returning: URLSessionWebSocketTask.CloseCode?.self
            ) { group in
                group.addTask {
                    for await code in delegate.closeCodes {
                        return code
                    }
                    return nil
                }
                group.addTask {
                    try? await Task.sleep(for: .seconds(3))
                    return nil
                }
                let result = await group.next() ?? nil
                group.cancelAll()
                return result
            }
            guard closeCode == .normalClosure else {
                throw NSError(domain: "WebSocketLoopbackCheck", code: 2)
            }
            session.finishTasksAndInvalidate()
            print("PASS: text message, pong callback, and normal close handshake")
        } catch {
            task.cancel()
            session.invalidateAndCancel()
            throw error
        }
    }
}

Start python3 ws_echo.py, compile the Swift source with swiftc -parse-as-library -o ws-client ws-client.swift, and run ./ws-client; the fixture prints CLOSE after receiving and echoing the close frame. Stop the fixture afterward. This checks a single controlled close exchange, not whether every network or server shutdown completes a graceful handshake.

Keep local fixture results separate from network-service guarantees. A successful loopback exercise proves the tested Foundation/OS pair can complete this controlled exchange; it does not test TLS trust, authentication, proxies, server deployment, reconnect policy, or a third-party protocol. For those, use a test endpoint and credentials owned by the project, with server-side logs and explicit failure handling.

Related:

Sources:

Comments