fix(tests): avoid blocking async test workers

This commit is contained in:
Qiu Yuzhou
2026-10-23 03:46:19 +08:00
parent 17848b6fde
commit 09e4d2e097
19 changed files with 252 additions and 149 deletions
@@ -60,7 +60,7 @@ extension ProxyRuntimeController {
private func firewallStatus(for executableURL: URL) async -> FirewallBlockStatus {
let checker = firewallChecker
return await Task.detached(priority: .utility) {
checker.status(for: executableURL)
await checker.status(for: executableURL)
}.value
}
@@ -69,7 +69,7 @@ extension ProxyRuntimeController {
) async -> EndpointHealthProbe.Outcome {
let probe = probe
return await Task.detached(priority: .utility) {
probe.probe(host: host, port: port, timeout: timeout)
await probe.probe(host: host, port: port, timeout: timeout)
}.value
}
@@ -2,11 +2,11 @@ import Foundation
/// 端点探测缝:注入以便控制器单测(真实探测走 EndpointHealthProbe)。
protocol EndpointProbing: Sendable {
func probe(host: String, port: Int, timeout: TimeInterval) -> EndpointHealthProbe.Outcome
func probe(host: String, port: Int, timeout: TimeInterval) async -> EndpointHealthProbe.Outcome
}
struct SystemEndpointProbe: EndpointProbing {
func probe(host: String, port: Int, timeout: TimeInterval) -> EndpointHealthProbe.Outcome {
func probe(host: String, port: Int, timeout: TimeInterval) async -> EndpointHealthProbe.Outcome {
EndpointHealthProbe.probe(host: host, port: port, timeout: timeout)
}
}
@@ -7,7 +7,7 @@ enum FirewallBlockStatus: Equatable, Sendable {
}
protocol FirewallStatusChecking: Sendable {
func status(for executableURL: URL) -> FirewallBlockStatus
func status(for executableURL: URL) async -> FirewallBlockStatus
}
/// `socketfilterfw --getappblocked` 是应用防火墙拒绝态的只读系统边界。
@@ -23,7 +23,11 @@ struct SocketFilterFirewallChecker: FirewallStatusChecking, @unchecked Sendable
self.runCommand = runCommand
}
func status(for executableURL: URL) -> FirewallBlockStatus {
func status(for executableURL: URL) async -> FirewallBlockStatus {
statusSynchronously(for: executableURL)
}
private func statusSynchronously(for executableURL: URL) -> FirewallBlockStatus {
guard
let output = try? runCommand(
Self.toolURL, ["--getappblocked", executableURL.path])
@@ -154,11 +154,11 @@ final class ListenerOccupancy: ObservableObject {
let excluded = Set(ProxyEndpointKind.allCases.map { context.proposedListen.port(for: $0) })
let probe = probe
let candidate = await Task.detached(priority: .utility) {
PortSuggestion.firstFree(excluding: excluded) { port in
await PortSuggestion.firstFreeAsync(excluding: excluded) { port in
let request = PortOccupancyProbeRequest(
endpoint: endpoint,
listen: context.proposedListen.replacingPort(port, for: endpoint), port: port)
if case .free = probe.occupancy(for: request) { return true }
if case .free = await probe.occupancy(for: request) { return true }
return false
}
}.value
@@ -202,15 +202,14 @@ final class ListenerOccupancy: ObservableObject {
-> [SettingsPortID: PortOccupancy]
{
await Task.detached(priority: .utility) {
Dictionary(
uniqueKeysWithValues: SettingsPortID.allCases.map { id in
let endpoint = SettingsPortAdapter.endpoint(for: id, group: context.draft.group)
return (
id,
probe.occupancy(
for: PortOccupancyProbeRequest(endpoint: endpoint, listen: context.proposedListen))
)
})
var results: [SettingsPortID: PortOccupancy] = [:]
for id in SettingsPortID.allCases {
let endpoint = SettingsPortAdapter.endpoint(for: id, group: context.draft.group)
let request = PortOccupancyProbeRequest(
endpoint: endpoint, listen: context.proposedListen)
results[id] = await probe.occupancy(for: request)
}
return results
}.value
}
}
@@ -52,7 +52,7 @@ struct BuiltinRuleCatalog {
/// App-process source facts. Concurrent consumers share one attempt per source;
/// successful bundled snapshots never expire, failed attempts require explicit retry.
actor BuiltinRuleSnapshots {
typealias Loader = @Sendable (RulesSource) throws -> RuleSnapshot
typealias Loader = @Sendable (RulesSource) async throws -> RuleSnapshot
private struct Attempt {
let id: UUID
let task: Task<RuleSnapshot, Error>
@@ -81,7 +81,7 @@ actor BuiltinRuleSnapshots {
attempt = Attempt(
id: UUID(),
task: Task.detached(priority: .userInitiated) {
let snapshot = try loader(source)
let snapshot = try await loader(source)
guard let kind = source.sourceKind, kind != .custom else {
throw RuleSnapshotError.missing
}
@@ -148,7 +148,7 @@ struct PortOccupancyProbeRequest: Equatable, Sendable {
/// 端口占用探测缝:设置编辑期的即时占用校验。激活是否成功
/// 仍以 runtime 实际绑定为准(健康门禁),本探测不构成权威判定。
protocol PortOccupancyProbing: Sendable {
func occupancy(for request: PortOccupancyProbeRequest) -> PortOccupancy
func occupancy(for request: PortOccupancyProbeRequest) async -> PortOccupancy
}
/// 系统实现:对 `bindAddress:port` 做一次不带复用选项的 TCP bind + listen,
@@ -156,7 +156,7 @@ protocol PortOccupancyProbing: Sendable {
/// 允许共存,到 listen 阶段才报告冲突,因此两步都要覆盖。占用时尽力以
/// `lsof` 解析监听进程名。探测不设置 SO_REUSEADDR/SO_REUSEPORT,避免假空闲。
struct SystemPortOccupancyProbe: PortOccupancyProbing {
func occupancy(for request: PortOccupancyProbeRequest) -> PortOccupancy {
func occupancy(for request: PortOccupancyProbeRequest) async -> PortOccupancy {
occupancy(port: request.port, listenerMode: request.listenerMode)
}
@@ -39,6 +39,15 @@ enum PortSuggestion {
}
return nil
}
static func firstFreeAsync(
excluding excludedPorts: Set<Int>, isFree: (Int) async -> Bool
) async -> Int? {
for candidate in range where !excludedPorts.contains(candidate) {
if await isFree(candidate) { return candidate }
}
return nil
}
}
extension ProxyListeningConfiguration {
@@ -105,14 +105,14 @@ final class ProxyPortSemanticsTests: XCTestCase {
}
}
func testBindProbeReportsUnknownWhenBindIsNotPermitted() throws {
func testBindProbeReportsUnknownWhenBindIsNotPermitted() async throws {
try XCTSkipIf(getuid() == 0, "root 可绑定特权端口,无法用 EACCES 构造 .unknown")
// 特权端口:非 root 进程 bind 失败(EACCES)≠ 占用,也不得冒充空闲。
let request = PortOccupancyProbeRequest(
endpoint: .socks,
listen: RuntimeListenFacts(listen: ProxyListeningConfiguration()),
port: 80)
let outcome = SystemPortOccupancyProbe().occupancy(for: request)
let outcome = await SystemPortOccupancyProbe().occupancy(for: request)
guard case .unknown = outcome else {
return XCTFail("绑定失败时必须报 .unknown,不得冒充空闲或占用,实际 \(outcome)")
@@ -72,12 +72,12 @@ extension ProxyRuntimeControllerModeTests {
func testAgentOffDuringModeRuleDerivationSupersedesDeployment() async throws {
let seeded = try makeSeededCatalog()
let started = expectation(description: "rule snapshot load paused")
let release = DispatchSemaphore(value: 0)
defer { release.signal() }
let release = TestAsyncGate("rule snapshot load release")
defer { release.release() }
let loader = BuiltinRuleSnapshots(loader: { source -> RuleSnapshot in
if source == .geolocationCN {
started.fulfill()
release.wait()
_ = await release.wait()
}
return rulesFixture(source)
})
@@ -94,7 +94,7 @@ extension ProxyRuntimeControllerModeTests {
await controller.setAgentEnabled(false)
XCTAssertEqual(controller.state, .off)
let registrationsAfterStop = agent.registerCount
release.signal()
release.release()
await modeSwitch.value
XCTAssertEqual(agent.registerCount, registrationsAfterStop, "派生被取代后不得部署")
@@ -9,11 +9,12 @@ final class BuiltinRuleCatalogTests: XCTestCase {
func testConcurrentConsumersShareOneSourceLoad() async throws {
let started = expectation(description: "source read")
let loader = SharedRuleSourceLoader(started: started)
let snapshots = BuiltinRuleSnapshots(loader: { try loader.load($0) })
defer { loader.release.release() }
let snapshots = BuiltinRuleSnapshots(loader: { try await loader.load($0) })
async let first = snapshots.load(.gfwlist)
await fulfillment(of: [started], timeout: 3)
async let second = snapshots.load(.gfwlist)
loader.release.signal()
loader.release.release()
let (one, two) = try await (first, second)
XCTAssertEqual(one, two)
_ = try await snapshots.load(.gfwlist)
@@ -22,7 +23,7 @@ final class BuiltinRuleCatalogTests: XCTestCase {
func testRetryReadsOnlyFailedSourcesAndSuccessNeverExpires() async throws {
let loader = SharedRuleSourceLoader()
let snapshots = BuiltinRuleSnapshots(loader: { try loader.load($0) })
let snapshots = BuiltinRuleSnapshots(loader: { try await loader.load($0) })
let first = await snapshots.browsingSources()
XCTAssertThrowsError(try first[.chinaIPv4]?.get())
_ = await snapshots.browsingSources()
@@ -37,7 +38,7 @@ final class BuiltinRuleCatalogTests: XCTestCase {
@MainActor
func testFirstBrowsingRetainsRuntimeSourceFailureUntilExplicitRetry() async throws {
let loader = SharedRuleSourceLoader()
let snapshots = BuiltinRuleSnapshots(loader: { try loader.load($0) })
let snapshots = BuiltinRuleSnapshots(loader: { try await loader.load($0) })
do {
_ = try await snapshots.load(.chinaIPv4)
XCTFail("The first runtime source request should fail")
@@ -187,17 +188,17 @@ private final class SharedRuleSourceLoader: @unchecked Sendable {
private let lock = NSLock()
private var counts: [RulesSource: Int] = [:]
private let started: XCTestExpectation?
let release = DispatchSemaphore(value: 0)
let release = TestAsyncGate("shared rule source release")
init(started: XCTestExpectation? = nil) { self.started = started }
func count(_ source: RulesSource) -> Int { lock.withLock { counts[source, default: 0] } }
func load(_ source: RulesSource) throws -> RuleSnapshot {
func load(_ source: RulesSource) async throws -> RuleSnapshot {
let count = lock.withLock {
counts[source, default: 0] += 1
return counts[source, default: 0]
}
if let started {
started.fulfill()
release.wait()
_ = await release.wait()
} else if source == .chinaIPv4 && count == 1 {
throw RuleSnapshotError.missing
}
@@ -124,7 +124,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests {
store: store,
settings: ProxyRuntimeSettings(
listen: ActivationFixture.listen, preferredMode: .global, agentEnabled: true),
proxyMode: .global, ruleSnapshots: BuiltinRuleSnapshots(loader: { loader.load($0) }))
proxyMode: .global, ruleSnapshots: BuiltinRuleSnapshots(loader: { await loader.load($0) }))
try await controller.activate(seeded.server)
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
let previous = try XCTUnwrap(runtimeStore.loadDocument())
@@ -164,7 +164,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests {
store: store,
settings: ProxyRuntimeSettings(
listen: ActivationFixture.listen, preferredMode: .global, agentEnabled: true),
proxyMode: .global, ruleSnapshots: BuiltinRuleSnapshots(loader: { loader.load($0) }))
proxyMode: .global, ruleSnapshots: BuiltinRuleSnapshots(loader: { await loader.load($0) }))
try await controller.activate(seeded.server)
let modeSwitch = Task { await controller.setProxyMode(.rule) }
try await withTestTask(
@@ -187,7 +187,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests {
let started = expectation(description: "background source preparation")
let loader = PausedRuntimeRuleSource(started: started)
defer { loader.resume() }
let snapshots = BuiltinRuleSnapshots(loader: { loader.load($0) })
let snapshots = BuiltinRuleSnapshots(loader: { await loader.load($0) })
let settings = ProxyRuntimeSettings(
listen: ActivationFixture.listen, preferredMode: .global, agentEnabled: true)
let controller = makeRuleEnablementController(
@@ -237,7 +237,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests {
store: store,
settings: ProxyRuntimeSettings(
listen: ActivationFixture.listen, preferredMode: .global, agentEnabled: true),
proxyMode: .global, ruleSnapshots: BuiltinRuleSnapshots(loader: { loader.load($0) }))
proxyMode: .global, ruleSnapshots: BuiltinRuleSnapshots(loader: { await loader.load($0) }))
try await controller.activate(seeded.server)
let old = Task { await controller.setProxyMode(.rule) }
try await withTestTask(
@@ -3,7 +3,7 @@ import XCTest
@testable import ShadowsocksX_NG2
final class FirewallStatusCheckerTests: XCTestCase {
func testGetAppBlockedOutputMapsToBlockedStatus() {
func testGetAppBlockedOutputMapsToBlockedStatus() async {
var capturedExecutable: URL?
var capturedArguments: [String] = []
let checker = SocketFilterFirewallChecker { executable, arguments in
@@ -13,14 +13,15 @@ final class FirewallStatusCheckerTests: XCTestCase {
}
let target = URL(fileURLWithPath: "/bundle/Helpers/sslocal")
XCTAssertEqual(checker.status(for: target), .blocked)
let blocked = await checker.status(for: target)
XCTAssertEqual(blocked, .blocked)
XCTAssertEqual(
capturedExecutable?.path,
"/usr/libexec/ApplicationFirewall/socketfilterfw")
XCTAssertEqual(capturedArguments, ["--getappblocked", target.path])
}
func testPermittedAndUnknownResultsDegradeWithoutFalseBlock() {
func testPermittedAndUnknownResultsDegradeWithoutFalseBlock() async {
let permitted = SocketFilterFirewallChecker { _, _ in
"Incoming connection to /bundle/Helpers/sslocal is permitted.\n"
}
@@ -29,7 +30,9 @@ final class FirewallStatusCheckerTests: XCTestCase {
}
let target = URL(fileURLWithPath: "/bundle/Helpers/sslocal")
XCTAssertEqual(permitted.status(for: target), .permitted)
XCTAssertEqual(unavailable.status(for: target), .unknown)
let permittedStatus = await permitted.status(for: target)
let unavailableStatus = await unavailable.status(for: target)
XCTAssertEqual(permittedStatus, .permitted)
XCTAssertEqual(unavailableStatus, .unknown)
}
}
@@ -155,7 +155,7 @@ struct ListenerOccupancyLifecycleTests {
private final class SuggestionProbe: PortOccupancyProbing, Sendable {
let gate = GatedOccupancyProbe(gatedAnswer: .free, passThroughAnswer: .free)
func occupancy(for request: PortOccupancyProbeRequest) -> PortOccupancy {
request.port >= PortSuggestion.range.lowerBound ? gate.occupancy(for: request) : .free
func occupancy(for request: PortOccupancyProbeRequest) async -> PortOccupancy {
request.port >= PortSuggestion.range.lowerBound ? await gate.occupancy(for: request) : .free
}
}
@@ -46,11 +46,16 @@ func withTestTask<Value: Sendable>(
await finish()
}
/// Broadcast release is remembered, including when cleanup runs before a worker enters.
final class TestBlockingGate: @unchecked Sendable {
private let group = DispatchGroup()
/// Broadcast async release is remembered, and timed-out waiters do not occupy a worker thread.
final class TestAsyncGate: @unchecked Sendable {
private struct Waiter {
let continuation: CheckedContinuation<Bool, Never>
var timeoutTask: Task<Void, Never>?
}
private let lock = NSLock()
private var released = false
private var waiters: [UUID: Waiter] = [:]
private let onTimeout: @Sendable () -> Void
init(
@@ -61,7 +66,6 @@ final class TestBlockingGate: @unchecked Sendable {
if let onTimeout {
self.onTimeout = onTimeout
} else if let test = Test.current {
// Detached workers have no current test; retain the creating test and source location.
let message = Comment(
rawValue: "Timed out waiting for \(description) (created by \(test.name))")
let location = SourceLocation(
@@ -70,22 +74,64 @@ final class TestBlockingGate: @unchecked Sendable {
} else {
self.onTimeout = { XCTFail("Timed out waiting for \(description)", file: file, line: line) }
}
group.enter()
}
func wait(timeout: TimeInterval = 10) -> Bool {
guard group.wait(timeout: .now() + timeout) == .success else {
onTimeout()
return false
func wait(timeout: Duration = .seconds(10)) async -> Bool {
let id = UUID()
return await withCheckedContinuation { continuation in
lock.lock()
guard !released else {
lock.unlock()
continuation.resume(returning: true)
return
}
waiters[id] = Waiter(continuation: continuation)
lock.unlock()
let timeoutTask = Task { [weak self] in
do {
try await Task.sleep(for: timeout)
self?.expire(id)
} catch {
// Release cancels the timer after resuming the waiter.
}
}
lock.lock()
if var waiter = waiters[id] {
waiter.timeoutTask = timeoutTask
waiters[id] = waiter
lock.unlock()
} else {
lock.unlock()
timeoutTask.cancel()
}
}
return true
}
func release() {
lock.lock()
let shouldRelease = !released
guard !released else {
lock.unlock()
return
}
released = true
let pending = Array(waiters.values)
waiters.removeAll()
lock.unlock()
if shouldRelease { group.leave() }
for waiter in pending {
waiter.timeoutTask?.cancel()
waiter.continuation.resume(returning: true)
}
}
private func expire(_ id: UUID) {
lock.lock()
let waiter = waiters.removeValue(forKey: id)
lock.unlock()
guard let waiter else { return }
onTimeout()
waiter.continuation.resume(returning: false)
}
}
@@ -0,0 +1,86 @@
import Foundation
import XCTest
@testable import ShadowsocksX_NG2
extension ProxyRuntimeFixture {
/// 可暂停探测替身:arm 后的下一次探测异步等待测试放行,在启动健康门的
/// await 窗口内确定性插入交错命令,不占用线程池工作线程。
final class BlockingProbe: EndpointProbing, @unchecked Sendable {
private let lock = NSLock()
private var gate: TestAsyncGate?
private var started: XCTestExpectation?
func arm(started: XCTestExpectation) {
lock.lock()
defer { lock.unlock() }
precondition(gate == nil, "上一次 arm 尚未 release")
gate = TestAsyncGate("runtime fixture release")
self.started = started
}
func release() {
lock.lock()
let gate = self.gate
self.gate = nil
self.started = nil
lock.unlock()
gate?.release()
}
func probe(host: String, port: Int, timeout: TimeInterval) async
-> EndpointHealthProbe.Outcome
{
let (gate, started) = armedGate()
if let gate {
started?.fulfill()
_ = await gate.wait()
}
return .reachable
}
private func armedGate() -> (TestAsyncGate?, XCTestExpectation?) {
lock.lock()
let gate = self.gate
let started = self.started
lock.unlock()
return (gate, started)
}
}
/// 按调用序返回预设结果,并可在指定调用处异步等待测试放行。
final class BlockingFirewallChecker: FirewallStatusChecking, @unchecked Sendable {
private let lock = NSLock()
private var callCount = 0
private let outcomes: [FirewallBlockStatus]
private let blockOnCall: Int
private let started: XCTestExpectation
private let gate = TestAsyncGate("runtime fixture release")
init(outcomes: [FirewallBlockStatus], blockOnCall: Int, started: XCTestExpectation) {
precondition(!outcomes.isEmpty)
self.outcomes = outcomes
self.blockOnCall = blockOnCall
self.started = started
}
func release() { gate.release() }
func status(for executableURL: URL) async -> FirewallBlockStatus {
let call = nextCall()
if call == blockOnCall {
started.fulfill()
_ = await gate.wait()
}
return outcomes[min(call, outcomes.count) - 1]
}
private func nextCall() -> Int {
lock.lock()
callCount += 1
let call = callCount
lock.unlock()
return call
}
}
}
@@ -206,7 +206,13 @@ enum ProxyRuntimeFixture {
return requestedPorts
}
func probe(host: String, port: Int, timeout: TimeInterval) -> EndpointHealthProbe.Outcome {
func probe(host: String, port: Int, timeout: TimeInterval) async
-> EndpointHealthProbe.Outcome
{
recordProbe(port: port)
}
private func recordProbe(port: Int) -> EndpointHealthProbe.Outcome {
lock.lock()
defer { lock.unlock() }
callCount += 1
@@ -239,7 +245,11 @@ enum ProxyRuntimeFixture {
self.outcomes = outcomes
}
func status(for executableURL: URL) -> FirewallBlockStatus {
func status(for executableURL: URL) async -> FirewallBlockStatus {
recordStatus(for: executableURL)
}
private func recordStatus(for executableURL: URL) -> FirewallBlockStatus {
lock.lock()
defer { lock.unlock() }
recordedURLs.append(executableURL)
@@ -249,75 +259,6 @@ enum ProxyRuntimeFixture {
}
}
/// 可暂停探测替身:arm 后的下一次探测挂起在信号量上,直到测试放行——
/// 在启动健康门的 await 窗口内确定性插入交错命令(探测运行在 detached
/// 线程,不占主 actor)。放行后恢复直通。
final class BlockingProbe: EndpointProbing, @unchecked Sendable {
private let lock = NSLock()
private var gate: TestBlockingGate?
private var started: XCTestExpectation?
func arm(started: XCTestExpectation) {
lock.lock()
defer { lock.unlock() }
precondition(gate == nil, "上一次 arm 尚未 release")
gate = TestBlockingGate("runtime fixture release")
self.started = started
}
func release() {
lock.lock()
let gate = self.gate
self.gate = nil
self.started = nil
lock.unlock()
gate?.release()
}
func probe(host: String, port: Int, timeout: TimeInterval) -> EndpointHealthProbe.Outcome {
lock.lock()
let gate = self.gate
let started = self.started
lock.unlock()
if let gate {
started?.fulfill()
_ = gate.wait()
}
return .reachable
}
}
/// 按调用序返回预设结果、并可在第 N 次调用处挂起的防火墙检查替身:
/// 钉住「轮询 await 恢复后代际已推进时不得再写状态」的回归。
final class BlockingFirewallChecker: FirewallStatusChecking, @unchecked Sendable {
private let lock = NSLock()
private var callCount = 0
private let outcomes: [FirewallBlockStatus]
private let blockOnCall: Int
private let started: XCTestExpectation
private let gate = TestBlockingGate("runtime fixture release")
init(outcomes: [FirewallBlockStatus], blockOnCall: Int, started: XCTestExpectation) {
precondition(!outcomes.isEmpty)
self.outcomes = outcomes
self.blockOnCall = blockOnCall
self.started = started
}
func release() { gate.release() }
func status(for executableURL: URL) -> FirewallBlockStatus {
lock.lock()
callCount += 1
let call = callCount
lock.unlock()
if call == blockOnCall {
started.fulfill()
_ = gate.wait()
}
return outcomes[min(call, outcomes.count) - 1]
}
}
}
/// 每个夹具使用独立 defaults 域,最后一个引用释放时清理持久值。
@@ -71,13 +71,13 @@ func withRuleApplicationTask<Value: Sendable>(
final class PausedRuntimeRuleSource: @unchecked Sendable {
let started: XCTestExpectation
private let release = TestBlockingGate("rule source release")
private let release = TestAsyncGate("rule source release")
init(started: XCTestExpectation) { self.started = started }
func resume() { release.release() }
func load(_ source: RulesSource) -> RuleSnapshot {
func load(_ source: RulesSource) async -> RuleSnapshot {
if source == .geolocationCN {
started.fulfill()
_ = release.wait()
_ = await release.wait()
}
return rulesFixture(source)
}
@@ -163,7 +163,11 @@ final class FakeOccupancyProbe: PortOccupancyProbing, @unchecked Sendable {
answerOverride = answer
}
func occupancy(for request: PortOccupancyProbeRequest) -> PortOccupancy {
func occupancy(for request: PortOccupancyProbeRequest) async -> PortOccupancy {
occupancySynchronously(for: request)
}
private func occupancySynchronously(for request: PortOccupancyProbeRequest) -> PortOccupancy {
lock.lock()
defer { lock.unlock() }
requestedPorts.append(request.port)
@@ -199,8 +203,14 @@ final class FakeOccupancyProbe: PortOccupancyProbing, @unchecked Sendable {
/// 被扣住,`passThrough` 之后的调用立即返回新应答。用于把「旧一轮」结果钉在
/// 新一轮之后落盘,验证过期结果不得覆盖。
final class GatedOccupancyProbe: PortOccupancyProbing, @unchecked Sendable {
private struct Invocation {
let isFirstEntry: Bool
let isGated: Bool
let answer: PortOccupancy
}
private let lock = NSLock()
private let gate = TestBlockingGate("occupancy probe release")
private let gate = TestAsyncGate("occupancy probe release")
private var isGated = true
private var enteredCount = 0
private let entry = XCTestExpectation(description: "occupancy probe entered")
@@ -239,17 +249,22 @@ final class GatedOccupancyProbe: PortOccupancyProbing, @unchecked Sendable {
gate.release()
}
func occupancy(for request: PortOccupancyProbeRequest) -> PortOccupancy {
func occupancy(for request: PortOccupancyProbeRequest) async -> PortOccupancy {
let invocation = record(request)
if invocation.isFirstEntry { entry.fulfill() }
if invocation.isGated, !(await gate.wait()) {
return .unknown(detail: "Test fixture release timed out")
}
return invocation.answer
}
private func record(_ request: PortOccupancyProbeRequest) -> Invocation {
lock.lock()
enteredCount += 1
let firstEntry = enteredCount == 1
let gated = isGated
let answer = gated ? gatedAnswer : passThroughAnswer
lock.unlock()
if firstEntry { entry.fulfill() }
if gated, !gate.wait() {
return .unknown(detail: "Test fixture release timed out")
}
return answer
return Invocation(isFirstEntry: firstEntry, isGated: gated, answer: answer)
}
}
@@ -40,27 +40,26 @@ struct BoundedTestWaitTests {
await #expect(throws: CancellationError.self) { try await task.value }
}
@Test func blockingGateTimesOutInsteadOfRetainingAWorker() async {
let gate = TestBlockingGate(onTimeout: {})
let worker = Task.detached { gate.wait(timeout: 0.02) }
#expect(await worker.value == false)
@Test func asyncGateTimesOutWithoutBlockingAWorker() async {
let gate = TestAsyncGate(onTimeout: {})
#expect(await gate.wait(timeout: .milliseconds(20)) == false)
}
@Test func blockingGateRemembersEarlyAndRepeatedRelease() {
let gate = TestBlockingGate()
@Test func asyncGateRemembersEarlyAndRepeatedRelease() async {
let gate = TestAsyncGate()
gate.release()
gate.release()
#expect(gate.wait(timeout: 0))
#expect(await gate.wait(timeout: .zero))
}
@Test func blockingGateReleasesEveryWorker() async throws {
let gate = TestBlockingGate()
@Test func asyncGateReleasesEveryWorker() async throws {
let gate = TestAsyncGate()
let entered = XCTestExpectation(description: "workers entered")
entered.expectedFulfillmentCount = 4
let workers = (0..<4).map { _ in
Task.detached {
entered.fulfill()
return gate.wait()
return await gate.wait()
}
}
try await requireTestPhase([entered])