云端 Mac 上的 iOS WebSocket 心跳与连接隔离测试

云端 Mac 上的 iOS WebSocket 心跳与连接隔离测试

聊天页已经恢复联网,却又被一次迟到的关闭回调踢回“连接中”。这类问题通常不是重连次数不够,而是旧连接仍能改写新会话。在 GNUMac 云端 Mac 上验收 iOS WebSocket 客户端时,先把心跳期限和连接身份从网络代码中拆出来,再做真实传输测试,比反复断网更容易复现边界。

先规定连接的身份与时间

每次创建传输任务都分配递增的代际编号。连接成功、Pong、接收失败和关闭回调必须携带创建时捕获的编号,而不是执行时读取“当前编号”。否则旧回调会被误认成新连接事件。

时间使用单调时钟,不用系统日期。生产环境可从 ContinuousClock 计算经过时长,测试则直接传入整数毫秒。本文采用一个明确策略:连接打开后等待一个间隔再发 Ping;等待 Pong 期间不再发送第二个 Ping;达到截止时刻仍未收到响应就关闭。

取消旧任务不是隔离屏障。已经排队的回调仍可能执行,接收事件的一端必须检查连接编号和当前阶段。

这份模型只处理单个连接的心跳与身份,不包含重连退避、消息重放或业务确认,避免把几种协议混成一个难以验收的对象。

写一个不依赖网络的状态机

把下面内容保存为 heartbeat.swift。tick 返回需要执行的动作,外层适配器负责真正发送 Ping 或取消传输任务。示例中的短间隔只用于测试,不是生产参数建议。

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)
    }
}

这里约定截止时刻收到的 Pong 也算迟到,保证同一时刻先处理定时器还是先处理响应,不会改变超时结论。实际关闭动作仍由下一次 tick 触发,因此调度器必须持续驱动检查。

用显式时刻覆盖重连边界

继续追加以下断言,以默认非优化方式执行 xcrun swift heartbeat.swift。断言失败会使进程非零退出;全部通过时没有输出。不要使用会禁用这些检查的构建设置。

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))

第二段刻意让旧连接的 Pong 落在新连接等待响应的区间。若删除代际校验,它就会错误延长新连接寿命。还应增加重复打开、空闲状态收到 Pong、超时后重复检查等用例,确认每次故障只生成一次关闭动作。

接入传输层时守住串行边界

真实客户端可由一个 actor 持有状态机。定时器、连接回调和取消请求都进入同一隔离域,禁止多个队列直接修改状态。动作里的编号同样要核对:若执行动作前已经创建新连接,旧的关闭动作不能取消当前任务。

使用 URLSessionWebSocketTask.sendPing 时,只在完成回调没有错误的情况下向模型提交 Pong 事件;有错误则进入带代际校验的故障处理。业务消息到达不等于 Pong,不能默认替代传输心跳。

为保持模型的“单个待确认 Ping”前提,适配器须保证每个 Ping 只提交一次完成事件。若换成可能重复回调的封装,再增加 Ping 请求编号;连接代际只能隔离不同连接,不能区分同一连接里的多次心跳。

最后补齐真实连接验收

离线断言通过后,再用可控测试端验证协议行为。至少记录正常响应、延迟响应、直接关闭和连接建立失败四种结果,并确认接收循环能持续读取后续消息。日志仅保留代际、事件类型、单调时间和状态变化,不记录令牌或业务正文。

应用进入后台后,定时回调不保证持续执行,不能将心跳模型误当成后台保活能力。恢复前台时先重新评估连接状态,再决定复用还是新建连接。模拟器可验证事件路由,真实设备仍要覆盖后台恢复与网络切换。

验收完成的标志不是“最终连上了”,而是旧事件不能修改新会话、超时边界没有歧义、每次关闭都有对应原因。把这些断言加入流水线,后续调整心跳间隔时就不必再次靠人工等待发现竞态。

常见问题

心跳测试需要真的断网或等待超时吗?

状态机单元测试不需要。把单调时间作为参数传入,直接检查截止时间前一刻和截止时刻;真实连接的 Ping/Pong、取消与网络切换另做集成测试。

为什么取消旧连接之后还要检查连接代际?

取消不会撤回已经进入执行队列的回调。每次创建连接递增代际编号,处理回调前同时核对编号与当前状态,才能阻止旧连接关闭或修改新会话。

为你的工作流配置一台独享 Mac mini

按项目选择机型、节点与租期。先核对内存、存储和连接需求,再提交订单;实际可用状态以控制台实时返回为准。

先确定工作流,再把机器开起来。

独享物理 Mac mini。选择机型、节点与租期后,再按项目需要配置存储。