云端 Mac 上验证 iOS 令牌刷新并发与退出隔离

云端 Mac 上验证 iOS 令牌刷新并发与退出隔离

列表、头像和消息计数同时请求接口,旧令牌失效后,三个请求一起收到认证失败。如果各自刷新,服务端轮换刷新凭据时可能相互覆盖;用户此时退出,晚到的响应还可能把登录状态写回来。云端 Mac 适合把这种竞争条件做成固定测试:不连生产接口,不依赖网络碰巧变慢,只验证认证协调器的行为。

先定义三条可验收约束

第一,同一会话内同时发生的刷新只执行一次。第二,退出之后,旧刷新结果不能更新当前凭据。第三,一个业务请求最多刷新并重试一次。认证失败是否允许刷新,应按接口协议判断,不能把所有权限错误都当成令牌过期。

协调器接收“本次请求实际使用的令牌”。若当前令牌已经不同,说明其他请求完成了更新,直接使用新值,不再刷新。否则,迟到的失败响应会触发第二轮无意义更新。

actor 只能保证隔离状态的访问,不能保证整个异步方法不可交错。执行到 await 时,退出操作和其他请求仍然可以进入。

示例把令牌简化为字符串,刷新传输以闭包注入。实际工程还要管理有效期、刷新凭据轮换和安全持久化;不要把完整凭据写进测试报告。

用共享 Task 合并刷新

在工程测试目标中加入以下协调器。它仅保存内存状态,不负责发送业务请求。刷新闭包应该在服务端返回明确失败或超时后抛错,不能无限等待。

import Foundation

actor RefreshGate {
    enum Failure: Error { case signedOut }

    private var token: String?
    private var generation = 0
    private var flight: (id: UUID, task: Task<String, Error>)?
    private let refresh: @Sendable () async throws -> String

    init(token: String, refresh: @escaping @Sendable () async throws -> String) {
        self.token = token
        self.refresh = refresh
    }

    func recover(rejected: String) async throws -> String {
        guard let current = token else { throw Failure.signedOut }
        if current != rejected {
            try Task.checkCancellation()
            return current
        }

        let epoch = generation
        let active: (id: UUID, task: Task<String, Error>)
        if let existing = flight {
            active = existing
        } else {
            let operation = refresh
            active = (UUID(), Task { try await operation() })
            flight = active
        }

        do {
            let updated = try await active.task.value
            guard generation == epoch else { throw Failure.signedOut }
            if flight?.id == active.id {
                token = updated
                flight = nil
            }
            try Task.checkCancellation()
            return updated
        } catch {
            if flight?.id == active.id {
                flight = nil
            }
            throw error
        }
    }

    func logout() {
        generation += 1
        token = nil
        flight?.task.cancel()
        flight = nil
    }
}

先登记 Task,再等待结果,其他调用才能复用它。清理时比较任务标识,防止旧调用恢复执行后误删新任务。这里的非结构化 Task 不会因为某个等待者取消而自动取消;这是有意保留的共享行为,不应额外挂取消处理器去终止它。

成功结果先更新共享状态,再检查调用者取消。这样已关闭页面不会继续消费结果,仍在等待的请求却能取得新令牌。取消检查只在等待结束后执行,因此它不是即时返回的取消接口。

在云端 Mac 跑本地并发测试

用一个计数器代替刷新接口,测试凭据只用固定假值。以下方法放进 XCTestCase 子类,协调器与测试在同一测试目标即可。

import XCTest

actor RefreshProbe {
    private(set) var calls = 0

    func run() async throws -> String {
        calls += 1
        try await Task.sleep(nanoseconds: 100_000_000)
        return "fresh-token"
    }
}

func testRefreshIsShared() async throws {
    let probe = RefreshProbe()
    let gate = RefreshGate(token: "old-token") {
        try await probe.run()
    }

    let values = try await withThrowingTaskGroup(of: String.self) { group in
        for _ in 0..<20 {
            group.addTask {
                try await gate.recover(rejected: "old-token")
            }
        }
        var output: [String] = []
        for try await value in group {
            output.append(value)
        }
        return output
    }

    let calls = await probe.calls
    XCTAssertEqual(calls, 1)
    XCTAssertEqual(values.count, 20)
    XCTAssertTrue(values.allSatisfy { $0 == "fresh-token" })
}

短暂停顿用于扩大重叠机会,不是性能指标。这项测试还应补上“刷新已经完成后再送入旧令牌失败”的用例,断言调用计数仍为一次。它可以发现只合并等待者、却遗漏迟到响应的实现。

退出竞争不要依赖固定睡眠。使用受控 continuation,让假接口通知测试“刷新已开始”,测试随后调用 logout,再放行结果。即使假接口忽略取消并返回成功,旧等待者也必须抛错;退出后的新调用则必须直接失败。

失败、取消与重试分别处理

不把共享失败变成重试风暴

刷新抛错时,现有等待者收到同一次失败,协调器清空任务。后续是否允许新尝试由上层策略决定:临时网络失败可以设置退避;凭据明确失效应关闭会话。不要让每个等待者在 catch 中立即再次调用 recover。

重试业务请求前,还要确认请求体能够重新发送。文件流、一次性数据源,以及会产生写入的操作,都需要重建请求或配合服务端幂等协议。令牌更新成功不意味着所有请求都可以安全重放。

退出是一道状态边界

任务取消是协作式的,传输层可能已经完成,也可能没有及时响应取消。因此退出必须同时清空凭据、递增代次。生产环境中的持久化也要受相同检查保护,不能让传输闭包自行写入新凭据,否则会绕过协调器的退出隔离。

把竞态变成固定回归项

提交前至少覆盖这些情况:

记录测试名称、刷新计数和最终状态即可,不记录令牌内容。在云端 Mac 上重复执行这组测试,重点不是证明竞争永远不会发生,而是确认竞争发生时,结果仍符合会话边界。

常见问题

一个页面取消请求时,应该取消共享刷新任务吗?

不应该。其他请求可能仍在等待同一次刷新。页面只取消自己的后续处理;退出会话时才取消共享任务,并递增会话代次阻止旧结果回写。

刷新后重试仍收到认证失败,可以继续刷新吗?

同一个业务请求最多刷新并重试一次。第二次认证失败应返回认证错误;会话是否失效按服务端协议判断,不能递归刷新形成无限循环。

为开发任务选一台独享云端 Mac

Apple Silicon 物理节点,非虚拟机。按天、周、月或季租用,机型与价格公开可查,实际可用状态以控制台实时返回为准。

立即租用云端 Mac 查看机型与租期