云端 Mac 上的 AsyncStream 缓冲与终止契约测试

云端 Mac 上的 AsyncStream 缓冲与终止契约测试

把下载进度、设备状态或通知回调接进 AsyncStream 后,界面正常刷新不代表事件流正确。消费者稍慢,缓冲就可能丢值;页面退出,底层订阅也可能继续运行。在 VpsKeep 云端 Mac 上,可以先用独立 Swift 测试固定这些行为,再接回应用,不必靠反复点击界面寻找竞态。

先确定哪些事件允许丢弃

进度百分比通常只需要最新值,适合 bufferingNewest;如果必须保留先到的事件,可以选 bufferingOldest。两者都不是无损队列。订单变更、文件写入确认等不能丢失的数据,需要额外的确认或持久化机制。

默认无界缓冲也不是万能解法。生产速度长期高于消费速度时,排队元素会持续占用内存。先写下三个约束:缓冲容量、满时保留谁、订阅何时释放。容量应根据业务可接受的积压确定,而不是根据机器剩余内存随意放大。

AsyncStream 的 yield 不会等待消费者腾出位置。有限缓冲提供的是丢弃策略,不是自动背压。

本文使用容量为 2 的整数流验证规则。整数代表事件序号,不是性能测量值。

建立独立测试工作区

在可写目录执行下面的命令,使用当前选定的开发工具链创建测试包。记录 Swift 版本,后续复跑保持同一工具链。包里不需要业务密钥,也不需要连接真实服务。

mkdir StreamContract
cd StreamContract
swift --version
swift package init --type library
mkdir -p Tests/StreamContractTests

把下一节代码保存为 Tests/StreamContractTests/StreamContractTests.swift。若初始化生成了同名文件,直接替换。模板生成的其他测试可以保留,下面只筛选本类。

为了让溢出结果确定,测试先同步生产三个元素,再启动消费。不要让生产者和消费者同时抢调度,也不要用“等待一秒”猜测缓冲已经填满。这里验收的是容器契约,不是线程运行速度。

同时检查丢值与保留序列

只断言最终数组还不够。代码还应检查第三次 yield 的返回值:保留最新元素时,被丢弃的是旧元素 1;保留最早元素时,被丢弃的是新元素 3。finish 后仍然可以读完已经进入缓冲的值。

import XCTest

final class StreamContractTests: XCTestCase {
    func testNewestKeepsLastTwo() async {
        let pair = AsyncStream<Int>.makeStream(
            bufferingPolicy: .bufferingNewest(2)
        )
        pair.continuation.yield(1)
        pair.continuation.yield(2)

        switch pair.continuation.yield(3) {
        case .dropped(let value):
            XCTAssertEqual(value, 1)
        default:
            XCTFail("Expected a dropped value")
        }

        pair.continuation.finish()
        var received: [Int] = []
        for await value in pair.stream {
            received.append(value)
        }
        XCTAssertEqual(received, [2, 3])

        switch pair.continuation.yield(4) {
        case .terminated:
            break
        default:
            XCTFail("Expected a terminated stream")
        }
    }

    func testOldestKeepsFirstTwo() async {
        let pair = AsyncStream<Int>.makeStream(
            bufferingPolicy: .bufferingOldest(2)
        )
        pair.continuation.yield(1)
        pair.continuation.yield(2)

        switch pair.continuation.yield(3) {
        case .dropped(let value):
            XCTAssertEqual(value, 3)
        default:
            XCTFail("Expected a dropped value")
        }

        pair.continuation.finish()
        var received: [Int] = []
        for await value in pair.stream {
            received.append(value)
        }
        XCTAssertEqual(received, [1, 2])
    }
}

生产代码处理 .dropped 时,应累计丢弃次数或触发业务补偿,不要在每个进度事件上同步写日志。处理 .terminated 时,应停止继续投递,而不是不断重试同一个 continuation。

验证取消会触发退订入口

在同一个文件中追加下面的扩展。流没有任何元素,消费者只能等待或响应取消。终止通知通过测试期望验收,不依赖固定休眠。两秒只是失败超时,不是执行步骤之间的等待。

extension StreamContractTests {
    func testCancellationReportsTermination() async {
        let ended = expectation(description: "Stream cancelled")
        let pair = AsyncStream<Int>.makeStream()

        pair.continuation.onTermination = { reason in
            switch reason {
            case .cancelled:
                ended.fulfill()
            case .finished:
                XCTFail("Unexpected normal finish")
            @unknown default:
                XCTFail("Unknown termination reason")
            }
        }

        let consumer = Task {
            for await _ in pair.stream {}
        }

        consumer.cancel()
        await consumer.value
        await fulfillment(of: [ended], timeout: 2)
    }
}

真实适配器应把期望回调换成订阅令牌的释放方法。终止处理不保证在主线程执行,不能直接更新界面。退订必须线程安全且幂等,也不要在持有适配器内部锁时调用可能反向触发回调的退订函数。

还要考虑订阅建立竞态:终止可能先发生,令牌后返回。适配器需要保存“已关闭”状态;晚到的令牌发现状态关闭后立即释放,避免只设置 onTermination 却漏掉这条路径。

接入流水线与交付检查

运行测试并将输出保存在流水线的普通测试记录中。固定输入意味着重复执行应得到同样的数组和丢弃元素;偶尔通过通常说明测试混入了真实计时器或共享事件源。

swift test --filter StreamContractTests

接回应用前,补齐四项检查:正常 finish 是否退订;任务取消是否退订;退订过程中迟到的回调是否被忽略;令牌晚于终止到达时是否立即释放。先用可手动投递的假事件源测试这些路径,再测试真实源。

不要让多个任务竞争读取同一个流来模拟广播。需要多个订阅者时,应明确设计分发层,并分别规定各订阅者的缓冲策略。最终保留测试代码、工具链版本和业务契约说明,让后来接入事件源的人知道哪些丢弃属于设计,哪些属于故障。

常见问题

设置有限缓冲后,生产者会等待消费者吗?

不会。yield 是同步调用,缓冲满时根据策略丢弃元素,并通过返回值报告结果。需要无损传输时,应另行设计确认、暂停或持久化机制。

消费者任务取消后,是否还要释放上游订阅?

要。应在 onTermination 中调用线程安全、幂等的退订逻辑。AsyncStream 的终止不会自动停止你接入的计时器或其他回调源。

把步骤放到独享云 Mac 上运行

租用整台物理 Mac mini,非虚拟机。按天、周、月、季选择周期,先核对机型和节点,再确认订单;实际可用状态以控制台实时返回为准。