云端 Mac 上的 Swift 超时竞速验收:取消与退出边界

云端 Mac 上的 Swift 超时竞速验收:取消与退出边界

跨时区交接云端 Mac 上的异步代码时,最难复现的一类故障是:界面已经显示超时,测试却迟迟不结束。原因通常不是计时器失效,而是底层操作没有响应取消。把工作和计时器放进任务组,只能建立竞速关系,不能强制中断任意代码。

下面用 Swift 的结构化并发建立可执行基线,再把结果、取消与真正退出分开验收。示例要求支持 Duration 和 Task.sleep(for:) 的工具链,命令行运行环境为 macOS 13 或更新版本;团队交接时应记录实际使用的编译器版本。

先定义超时承诺,而不是先写计时器

将契约写成三条:工作先完成则返回结果;计时分支先被观察到则抛出超时错误;父任务取消时传播取消,不把它转换成业务超时。两个分支接近同时完成时,不承诺固定胜者。

还要区分相对等待与严格截止时间。下面的计时从计时分支执行等待时开始,不代表调用入口之后的绝对响应上限;调度延迟也不能靠一个很短的等待值消除。

超时触发、取消请求发出、底层操作退出,是三个不同事件。只有最后一个发生,任务组才具备结束的条件。

如果业务要求到点后立刻返回,就不能同时声称所有工作仍受这个任务组约束。需要重新设计底层取消接口,或把不可中断工作放进独立进程并管理其生命周期。

实现一个能注入等待逻辑的竞速器

把以下内容保存为 Timeout.swift。等待闭包既能连接真实时钟,也能在测试里换成可控闸门;工作结果必须满足跨任务传递的要求。

import Foundation

enum TimeoutFailure: Error {
    case expired
}

func raceTimeout<Value: Sendable>(
    wait: @escaping @Sendable () async throws -> Void,
    operation: @escaping @Sendable () async throws -> Value
) async throws -> Value {
    try Task.checkCancellation()

    return try await withThrowingTaskGroup(of: Value.self) { group in
        defer { group.cancelAll() }

        group.addTask {
            try Task.checkCancellation()
            return try await operation()
        }

        group.addTask {
            try Task.checkCancellation()
            try await wait()
            try Task.checkCancellation()
            throw TimeoutFailure.expired
        }

        guard let value = try await group.next() else {
            throw CancellationError()
        }

        try Task.checkCancellation()
        return value
    }
}

先用编译命令检查类型与并发约束,再接入工程测试:

xcrun swiftc -swift-version 6 -strict-concurrency=complete \
  -parse-as-library -emit-module \
  -module-name Timeout Timeout.swift \
  -emit-module-path /tmp/Timeout.swiftmodule

真实计时的调用片段如下,调用方须处于异步上下文:

let result = try await raceTimeout(
    wait: { try await Task.sleep(for: .seconds(2)) },
    operation: {
        try await Task.sleep(for: .milliseconds(20))
        return "ready"
    }
)

defer 保证成功和抛错路径都会请求取消剩余分支。计时器取消后会抛错;不要用 try? 吞掉该错误,否则它可能继续执行并被误报成超时。

用闸门固定顺序,避免靠睡眠碰运气

核心单元测试不要写成“等待一点,再猜哪个分支先运行”。建立两个测试闸门,分别控制工作完成和计时完成;另设启动信号,确认两个分支都已进入等待后再释放目标闸门。

闸门应记录取消状态,并通过取消处理器释放等待者。若内部用 continuation,必须保证只恢复一次;取消先到、等待者后注册的顺序也要覆盖。否则测试夹具自身就会制造卡死。

至少保留以下四组断言:

每组不仅检查调用结果,还应等待两个分支的结束标记。结果正确但子任务未退出,仍不能算验收通过。

专门证明不响应取消会阻塞退出

增加一个故意忽略取消、但可以由测试释放的工作闸门。先让计时分支获胜,再确认工作已收到取消请求,而外层调用仍未完成;最后释放工作闸门,确认外层才抛出超时。

这个用例证明的是结构化并发的等待语义,不是鼓励忽略取消。测试必须有释放兜底,不能用无限循环模拟卡死,也不能让断言失败后遗留永不恢复的 continuation。

生产代码应沿调用链检查:长循环是否定期执行 Task.checkCancellation();回调适配是否调用底层取消句柄;阻塞调用能否返回。仅在操作入口检查一次,无法覆盖后续长时间工作。

把退出证据一起交接给下一班

在云端 Mac 上运行时,将提交标识、编译器版本、用例名、胜出分支、取消观察和两个退出标记写入测试附件。耗时使用单调时钟计算,不要用不同成员桌面的墙上时间相减。

真实时钟测试只验证生产等待闭包接线正确,给调度保留余量,不要求精确到某一毫秒。测试进程还需独立的执行上限,因为一个不结束的任务组无法依靠自己的计时子任务自救。

最后检查错误处理没有把 CancellationError 全部改写成超时,等待闭包没有吞错,失败路径没有漏掉退出证据。交接完成的标准不是“计时器响过”,而是结果可解释、取消可观察、剩余工作已退出。

常见问题

任务组中的计时分支抛错后,超时函数会立即返回吗?

不一定。离开任务组作用域前仍要等待全部子任务结束;cancelAll 只发送取消请求,不会强制终止不响应取消的操作。

测试超时逻辑必须真的等待几秒吗?

核心契约测试不需要。可以注入可控的等待闭包,用测试闸门决定分支完成顺序;真实时钟只保留少量集成测试,并设置独立的测试执行上限。

为下一项任务选择云端 Mac

按团队主要所在地选择节点,使用整机独享的物理 Mac mini。浏览器远程桌面需要现代浏览器与稳定网络;配置、计费周期和实际可用状态请在下单前核对。

把下一次连接安排在近处

先核对配置与周期,再选择团队主要使用地区附近的节点。