列表、头像和消息计数同时请求接口,旧令牌失效后,三个请求一起收到认证失败。如果各自刷新,服务端轮换刷新凭据时可能相互覆盖;用户此时退出,晚到的响应还可能把登录状态写回来。云端 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 上重复执行这组测试,重点不是证明竞争永远不会发生,而是确认竞争发生时,结果仍符合会话边界。
常见问题
一个页面取消请求时,应该取消共享刷新任务吗?
不应该。其他请求可能仍在等待同一次刷新。页面只取消自己的后续处理;退出会话时才取消共享任务,并递增会话代次阻止旧结果回写。
刷新后重试仍收到认证失败,可以继续刷新吗?
同一个业务请求最多刷新并重试一次。第二次认证失败应返回认证错误;会话是否失效按服务端协议判断,不能递归刷新形成无限循环。