From f1214c4cf558d4d6c435f1ccba26de47b6a2223e Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Mon, 19 Oct 2026 04:02:05 +0800 Subject: [PATCH] test: bound asynchronous fixture waits --- ShadowsocksX-NG2/README.md | 3 + ShadowsocksX-NG2/Taskfile.dist.yml | 3 + .../Rules/RuleEnablementControllerTests.swift | 290 +++++++++--------- .../Rules/RuleRuntimeDelayGateTests.swift | 59 ++++ .../Settings/LANListeningWorkflowTests.swift | 18 +- .../Tests/Support/BoundedTestWait.swift | 91 ++++++ .../Tests/Support/ProxyRuntimeFixture.swift | 14 +- .../Tests/Support/RuleRuntimeDelayGate.swift | 84 +++++ .../Support/SettingsWorkflowTestSupport.swift | 23 +- .../TestSupport/BoundedTestWaitTests.swift | 71 +++++ 10 files changed, 483 insertions(+), 173 deletions(-) create mode 100644 ShadowsocksX-NG2/Tests/Rules/RuleRuntimeDelayGateTests.swift create mode 100644 ShadowsocksX-NG2/Tests/Support/BoundedTestWait.swift create mode 100644 ShadowsocksX-NG2/Tests/Support/RuleRuntimeDelayGate.swift create mode 100644 ShadowsocksX-NG2/Tests/TestSupport/BoundedTestWaitTests.swift diff --git a/ShadowsocksX-NG2/README.md b/ShadowsocksX-NG2/README.md index ec63e0b5..8b6c7655 100644 --- a/ShadowsocksX-NG2/README.md +++ b/ShadowsocksX-NG2/README.md @@ -34,6 +34,7 @@ ShadowsocksX-NG 的现代化重写版本。新工程的全部源码与构建配 跨进程契约按**文件**编入其它 target:`Runtime/` 的 10 个文件同时编入 wrapper(`RuntimePaths`、`SslocalRuntimeDocument`、`RuntimeLog` 等),`SystemProxy/` 全部 6 个文件编入特权 helper。`project.yml` 里这 16 条是显式路径,在 `Domain/` 内移动文件必须同步,漏改会让 `xcodegen generate` 以缺文件直接失败。布局守卫(`Tests/Architecture/SkeletonTests.swift` 的 `DomainLayoutTests`)断言根下无平铺 `.swift`、树下只有 `.swift`(非源码文件会被 XcodeGen 收进 Resources 阶段并封进签名后的 bundle)。 - `Tests/` — 单元测试 target,随 `ShadowsocksX-NG2` scheme 运行。目录即主题,根下不留平铺测试文件: - `Architecture/` — 跨切面工程约束守卫(分层与布局、平台效应 seam、开窗落点、清单完整性),扫源码或构建产物而不驱动行为。 + - `TestSupport/` — 测试等待与清理 helper 的故障回归用例。 - `Support/` — 夹具、替身与 helper,不含用例(`CatalogFixtures`、`ProxyRuntimeFixture`、`SystemProxyFakes`、`AppArtifact`、`TestSourceTree` 等)。 - `Agent/`、`Activation/`、`Catalog/`(工作流用例在 `Catalog/Workflow/`)、`Subscription/`、`Rules/`(自定义规则在 `Rules/Custom/`)、`ProxyRuntime/`、`ProxyControl/`、`Runtime/`、`Settings/`、`SystemProxy/`、`Plugin/`、`Diagnostics/`、`Credentials/`、`Workspace/`、`Servers/` — 按功能领域的用例,与生产代码的领域主题和 `*Workflow` 同名同界。 - `Smoke/` — 真实进程冒烟,独立 target 与 scheme(`task test:smoke`)。 @@ -97,6 +98,8 @@ task --exit-code --taskfile ShadowsocksX-NG2/Taskfile.dist.yml validate -- lint 统一入口成功表示所选关卡的命令通过,仍需核对证据是否完整;hostless、真实进程 smoke、原生 UI 和真实系统代理的验收边界仍分别成立,发布打包门槛见下文。 +`task test` 与 `task test:smoke` 显式启用 Xcode 单测试超时:默认 120 秒,上限 300 秒。这是 runner 兜底,不保证测试任务正常清理,也不是性能门槛。规则阶段等待失败会抛错退出测试体;受保护的测试任务先释放夹具,再取消并等待结束。规则延迟 gate 和同步阻塞夹具的默认等待上限为 10 秒,超时必须报告失败。共享 `AsyncGate` 保留由用例控制的取消语义。Swift Testing 的 detached worker 超时报告保留创建用例名称和源码位置;应同时核对命令退出码与测试计数,不能仅凭单例通过数判断成功。 + 在本目录内,日常格式化、静态检查、工程生成、构建和测试优先使用 `Taskfile.dist.yml` 定义的任务: ```bash diff --git a/ShadowsocksX-NG2/Taskfile.dist.yml b/ShadowsocksX-NG2/Taskfile.dist.yml index bb449da7..986864d4 100644 --- a/ShadowsocksX-NG2/Taskfile.dist.yml +++ b/ShadowsocksX-NG2/Taskfile.dist.yml @@ -5,6 +5,7 @@ vars: PROJECT_NAME: "ShadowsocksX-NG2" BUILD_DIR: "{{ .TASKFILE_DIR }}/.build" DERIVED_DATA_DIR: "{{ .BUILD_DIR }}/derivedData" + TEST_TIMEOUT_ARGS: '-test-timeouts-enabled YES -default-test-execution-time-allowance 120 -maximum-test-execution-time-allowance 300' tasks: validate: @@ -167,6 +168,7 @@ tasks: -destination 'platform=macOS,arch=arm64' -derivedDataPath {{ .DERIVED_DATA_DIR }} {{if .RESULT_BUNDLE_PATH}}-resultBundlePath {{.RESULT_BUNDLE_PATH | shellQuote}}{{end}} + {{.TEST_TIMEOUT_ARGS}} test test:binaries: @@ -192,6 +194,7 @@ tasks: -destination 'platform=macOS,arch=arm64' -derivedDataPath {{ .DERIVED_DATA_DIR }} {{if .RESULT_BUNDLE_PATH}}-resultBundlePath {{.RESULT_BUNDLE_PATH | shellQuote}}{{end}} + {{.TEST_TIMEOUT_ARGS}} test clean: diff --git a/ShadowsocksX-NG2/Tests/Rules/RuleEnablementControllerTests.swift b/ShadowsocksX-NG2/Tests/Rules/RuleEnablementControllerTests.swift index 6bb48dec..6c0d025a 100644 --- a/ShadowsocksX-NG2/Tests/Rules/RuleEnablementControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/Rules/RuleEnablementControllerTests.swift @@ -38,23 +38,28 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { store: store, settings: ProxySettings( listen: ActivationFixture.listen, preferredMode: .rule, agentEnabled: true), - proxyMode: .rule, ruleApplicationDelay: { await applicationGate.wait() }) + proxyMode: .rule, ruleApplicationDelay: { try await applicationGate.wait() }) try await controller.activate(seeded.server) let before = agent.registerCount let one = await controller.commitRuleDocument(CustomRuleDocument(rules: [first])) - await fulfillment(of: [applicationStarted], timeout: 3) - let two = await controller.commitRuleDocument(CustomRuleDocument(rules: [latest])) - XCTAssertEqual(one.outcome, .saved) - XCTAssertEqual(two.outcome, .saved) - XCTAssertEqual(try store.load(), [latest]) - XCTAssertEqual( - agent.registerCount, before, "Saving does not wait for or start deployment inline") - applicationGate.resume() - await controller.ruleApplicationTask?.value - XCTAssertEqual(agent.registerCount, before + 1) - let content = try activeACLContent(RuntimeFileStore(fileURL: runtime.contract)) - XCTAssertTrue(content.contains("latest-save.example")) - XCTAssertFalse(content.contains("first-save.example")) + try await withRuleApplicationTask( + try XCTUnwrap(controller.ruleApplicationTask), controller: controller, + release: applicationGate.resume, + body: { + try await requireTestPhase([applicationStarted], timeout: 3) + let two = await controller.commitRuleDocument(CustomRuleDocument(rules: [latest])) + XCTAssertEqual(one.outcome, .saved) + XCTAssertEqual(two.outcome, .saved) + XCTAssertEqual(try store.load(), [latest]) + XCTAssertEqual( + agent.registerCount, before, "Saving does not wait for or start deployment inline") + applicationGate.resume() + await controller.ruleApplicationTask?.value + XCTAssertEqual(agent.registerCount, before + 1) + let content = try activeACLContent(RuntimeFileStore(fileURL: runtime.contract)) + XCTAssertTrue(content.contains("latest-save.example")) + XCTAssertFalse(content.contains("first-save.example")) + }) } func testSaveDuringDeploymentIsAcceptedAndLatestDocumentConvergesAfterIt() async throws { @@ -70,7 +75,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { settings: ProxySettings( listen: ActivationFixture.listen, preferredMode: .rule, agentEnabled: true), proxyMode: .rule, launchHealthTimeoutSeconds: 0.3, - launchHealthRetryDelay: { await healthGate.wait() }) + launchHealthRetryDelay: { try await healthGate.wait() }) try await controller.activate(seeded.server) let runtimeStore = RuntimeFileStore(fileURL: runtime.contract) let started = expectation(description: "first deployment awaiting receipt") @@ -88,20 +93,26 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { } let one = await controller.commitRuleDocument(CustomRuleDocument(rules: [first])) XCTAssertEqual(one.outcome, .saved) - await fulfillment(of: [started, healthWaiting], timeout: 3) - let two = await controller.commitRuleDocument(CustomRuleDocument(rules: [latest])) - XCTAssertEqual(two.outcome, .saved) - XCTAssertEqual(try store.load(), [latest]) - XCTAssertEqual(agent.registerCount, before + 1, "A new save cannot overlap rule deployments") - try runtimeStore.writeRuntimeReceipt(for: XCTUnwrap(firstDocument), processID: 42) - healthGate.resume() - await controller.ruleApplicationTask?.value - XCTAssertEqual(try store.load(), [latest]) - XCTAssertEqual(agent.registerCount, before + 2) - let content = try activeACLContent(runtimeStore) - XCTAssertTrue(content.contains("after-flight.example")) - XCTAssertFalse(content.contains("in-flight.example")) - XCTAssertEqual(controller.state, .running) + try await withRuleApplicationTask( + try XCTUnwrap(controller.ruleApplicationTask), controller: controller, + release: healthGate.resume, + body: { + try await requireTestPhase([started, healthWaiting], timeout: 3) + let two = await controller.commitRuleDocument(CustomRuleDocument(rules: [latest])) + XCTAssertEqual(two.outcome, .saved) + XCTAssertEqual(try store.load(), [latest]) + XCTAssertEqual( + agent.registerCount, before + 1, "A new save cannot overlap rule deployments") + try runtimeStore.writeRuntimeReceipt(for: XCTUnwrap(firstDocument), processID: 42) + healthGate.resume() + await controller.ruleApplicationTask?.value + XCTAssertEqual(try store.load(), [latest]) + XCTAssertEqual(agent.registerCount, before + 2) + let content = try activeACLContent(runtimeStore) + XCTAssertTrue(content.contains("after-flight.example")) + XCTAssertFalse(content.contains("in-flight.example")) + XCTAssertEqual(controller.state, .running) + }) } func testNewPreparationStopsRemainingActionsOfAnAwaitingRuntimeOperation() async throws { @@ -109,7 +120,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { let (store, _) = try makeCustomRuleStore() let started = expectation(description: "new source preparation") let loader = PausedRuntimeRuleSource(started: started) - defer { loader.release.signal() } + defer { loader.resume() } let controller = makeRuleEnablementController( store: store, settings: ProxySettings( @@ -122,17 +133,26 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { let unregistered = expectation(description: "old operation waiting for wrapper exit") agent.onUnregister = { unregistered.fulfill() } let oldOperation = Task { await controller.execute(.stop, document: nil) } - await fulfillment(of: [unregistered], timeout: 3) - let newMode = Task { await controller.setProxyMode(.rule) } - await fulfillment(of: [started], timeout: 3) - try FileManager.default.removeItem(at: runtimeStore.pidFileURL) - let completed = await oldOperation.value - XCTAssertFalse(completed.succeeded) - XCTAssertEqual(runtimeStore.loadDocument(), previous, "Stale stop must not delete the contract") - loader.release.signal() - await newMode.value - XCTAssertEqual(controller.lastDocument?.aclRuntime?.summary, "rule-proxy-default") - XCTAssertEqual(controller.state, .running) + try await withTestTask( + oldOperation, release: { loader.resume() }, + body: { + try await requireTestPhase([unregistered], timeout: 3) + let newMode = Task { await controller.setProxyMode(.rule) } + try await withTestTask( + newMode, release: { loader.resume() }, + body: { + try await requireTestPhase([started], timeout: 3) + try FileManager.default.removeItem(at: runtimeStore.pidFileURL) + let completed = await oldOperation.value + XCTAssertFalse(completed.succeeded) + XCTAssertEqual( + runtimeStore.loadDocument(), previous, "Stale stop must not delete the contract") + loader.resume() + await newMode.value + XCTAssertEqual(controller.lastDocument?.aclRuntime?.summary, "rule-proxy-default") + XCTAssertEqual(controller.state, .running) + }) + }) } func testSystemProxyToggleDoesNotDiscardPendingRuleModePreparation() async throws { @@ -140,7 +160,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { let (store, _) = try makeCustomRuleStore() let started = expectation(description: "background source preparation") let loader = PausedRuntimeRuleSource(started: started) - defer { loader.release.signal() } + defer { loader.resume() } let controller = makeRuleEnablementController( store: store, settings: ProxySettings( @@ -148,14 +168,18 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { proxyMode: .global, ruleSnapshots: BuiltinRuleSnapshots(loader: { loader.load($0) })) try await controller.activate(seeded.server) let modeSwitch = Task { await controller.setProxyMode(.rule) } - await fulfillment(of: [started], timeout: 3) - await controller.setSystemProxyEnabled(true) - loader.release.signal() - await modeSwitch.value - XCTAssertEqual(controller.proxyMode, .rule) - XCTAssertTrue(controller.settings.systemProxyEnabled) - XCTAssertEqual(controller.lastDocument?.aclRuntime?.summary, "rule-proxy-default") - XCTAssertEqual(controller.state, .running) + try await withTestTask( + modeSwitch, release: { loader.resume() }, + body: { + try await requireTestPhase([started], timeout: 3) + await controller.setSystemProxyEnabled(true) + loader.resume() + await modeSwitch.value + XCTAssertEqual(controller.proxyMode, .rule) + XCTAssertTrue(controller.settings.systemProxyEnabled) + XCTAssertEqual(controller.lastDocument?.aclRuntime?.summary, "rule-proxy-default") + XCTAssertEqual(controller.state, .running) + }) } func testOffIntentDuringSharedSourcePreparationCannotBeOverwritten() async throws { @@ -163,7 +187,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { let (store, _) = try makeCustomRuleStore() let started = expectation(description: "background source preparation") let loader = PausedRuntimeRuleSource(started: started) - defer { loader.release.signal() } + defer { loader.resume() } let snapshots = BuiltinRuleSnapshots(loader: { loader.load($0) }) let settings = ProxySettings( listen: ActivationFixture.listen, preferredMode: .global, agentEnabled: true) @@ -171,29 +195,37 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { store: store, settings: settings, proxyMode: .global, ruleSnapshots: snapshots) try await controller.activate(seeded.server) let modeSwitch = Task { await controller.setProxyMode(.rule) } - await fulfillment(of: [started], timeout: 3) - let identity = RuleIdentity(action: .proxy, match: .domainExact("kept.example")) - let ruleCommit = Task { - await controller.commitRuleDocument( - CustomRuleDocument(rules: [], disabledIdentities: [identity])) - } - // Persistence happens before the awaited source. Observe the owner, not timing. - while controller.ruleDocuments.current?.disabledIdentities.contains(identity) != true { - await Task.yield() - } - await controller.setAgentEnabled(false) - let registrationsAfterStop = agent.registerCount - loader.release.signal() - await modeSwitch.value - let result = await ruleCommit.value - XCTAssertEqual(result.outcome, .saved) - await controller.ruleApplicationTask?.value - XCTAssertEqual(result.document?.disabledIdentities, [identity]) - XCTAssertEqual(try store.loadDocument().disabledIdentities, [identity]) - XCTAssertEqual(controller.state, .off) - XCTAssertFalse(controller.settings.agentEnabled) - XCTAssertEqual(agent.registerCount, registrationsAfterStop) - XCTAssertNil(RuntimeFileStore(fileURL: runtime.contract).loadDocument()) + try await withTestTask( + modeSwitch, release: { loader.resume() }, + body: { + try await requireTestPhase([started], timeout: 3) + let identity = RuleIdentity(action: .proxy, match: .domainExact("kept.example")) + let ruleCommit = Task { + await controller.commitRuleDocument( + CustomRuleDocument(rules: [], disabledIdentities: [identity])) + } + try await withRuleApplicationTask( + ruleCommit, controller: controller, release: loader.resume, + body: { + // Persistence happens before the awaited source. Observe the owner, not timing. + try await requireTestCondition("rule document persisted") { + controller.ruleDocuments.current?.disabledIdentities.contains(identity) == true + } + await controller.setAgentEnabled(false) + let registrationsAfterStop = agent.registerCount + loader.resume() + await modeSwitch.value + let result = await ruleCommit.value + XCTAssertEqual(result.outcome, .saved) + await controller.ruleApplicationTask?.value + XCTAssertEqual(result.document?.disabledIdentities, [identity]) + XCTAssertEqual(try store.loadDocument().disabledIdentities, [identity]) + XCTAssertEqual(controller.state, .off) + XCTAssertFalse(controller.settings.agentEnabled) + XCTAssertEqual(agent.registerCount, registrationsAfterStop) + XCTAssertNil(RuntimeFileStore(fileURL: runtime.contract).loadDocument()) + }) + }) } func testNewModeSupersedesRulePreparationWithoutRestoringOldMode() async throws { @@ -201,7 +233,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { let (store, _) = try makeCustomRuleStore() let started = expectation(description: "background source preparation") let loader = PausedRuntimeRuleSource(started: started) - defer { loader.release.signal() } + defer { loader.resume() } let controller = makeRuleEnablementController( store: store, settings: ProxySettings( @@ -209,14 +241,18 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { proxyMode: .global, ruleSnapshots: BuiltinRuleSnapshots(loader: { loader.load($0) })) try await controller.activate(seeded.server) let old = Task { await controller.setProxyMode(.rule) } - await fulfillment(of: [started], timeout: 3) - await controller.setProxyMode(.direct) - loader.release.signal() - await old.value - XCTAssertEqual(controller.proxyMode, .direct) - XCTAssertEqual(controller.state, .running) - XCTAssertEqual( - RuntimeFileStore(fileURL: runtime.contract).loadDocument()?.aclRuntime?.summary, "direct") + try await withTestTask( + old, release: { loader.resume() }, + body: { + try await requireTestPhase([started], timeout: 3) + await controller.setProxyMode(.direct) + loader.resume() + await old.value + XCTAssertEqual(controller.proxyMode, .direct) + XCTAssertEqual(controller.state, .running) + XCTAssertEqual( + RuntimeFileStore(fileURL: runtime.contract).loadDocument()?.aclRuntime?.summary, "direct") + }) } func testOffSaveUsesOwnedDocumentAndNewSessionRejectsCorruption() async throws { @@ -268,7 +304,7 @@ extension RuleEnablementControllerTests { launchHealthTimeoutSeconds: 0.05, launchHealthRetryDelay: { if deploymentIsRunning { - await deploymentGate.wait() + try await deploymentGate.wait() } else { try await Task.sleep(for: .milliseconds(1)) } @@ -281,27 +317,31 @@ extension RuleEnablementControllerTests { } let disabledDocument = CustomRuleDocument(rules: [rule], disabledIdentities: [rule.identity]) let pending = Task { await controller.updateRuleDocument(disabledDocument) } - await fulfillment(of: [deploymentWaiting], timeout: 5) - if switchMode { - await controller.setProxyMode(.global) - } else { - await controller.setAgentEnabled(false) - } - let registrations = agent.registerCount - deploymentGate.resume() - let outcome = await pending.value - XCTAssertEqual(outcome, .saved) - await controller.ruleApplicationTask?.value - XCTAssertEqual(try store.loadDocument().disabledIdentities, [rule.identity]) - XCTAssertEqual(agent.registerCount, registrations) - if switchMode { - XCTAssertEqual(controller.proxyMode, .global) - XCTAssertEqual(runtimeStore.loadDocument()?.aclRuntime?.summary, "global") - } else { - XCTAssertFalse(controller.settings.agentEnabled) - XCTAssertEqual(controller.state, .off) - XCTAssertNil(runtimeStore.loadDocument()) - } + try await withRuleApplicationTask( + pending, controller: controller, release: deploymentGate.resume, + body: { + try await requireTestPhase([deploymentWaiting], timeout: 5) + if switchMode { + await controller.setProxyMode(.global) + } else { + await controller.setAgentEnabled(false) + } + let registrations = agent.registerCount + deploymentGate.resume() + let outcome = await pending.value + XCTAssertEqual(outcome, .saved) + await controller.ruleApplicationTask?.value + XCTAssertEqual(try store.loadDocument().disabledIdentities, [rule.identity]) + XCTAssertEqual(agent.registerCount, registrations) + if switchMode { + XCTAssertEqual(controller.proxyMode, .global) + XCTAssertEqual(runtimeStore.loadDocument()?.aclRuntime?.summary, "global") + } else { + XCTAssertFalse(controller.settings.agentEnabled) + XCTAssertEqual(controller.state, .off) + XCTAssertNil(runtimeStore.loadDocument()) + } + }) } private func configureDeploymentReceipts( @@ -345,43 +385,3 @@ extension RuleEnablementControllerTests { XCTAssertNotEqual(controller.state, .running) } } - -private final class PausedRuntimeRuleSource: @unchecked Sendable { - let started: XCTestExpectation - let release = DispatchSemaphore(value: 0) - init(started: XCTestExpectation) { self.started = started } - func load(_ source: RulesSource) -> RuleSnapshot { - if source == .geolocationCN { - started.fulfill() - release.wait() - } - return rulesFixture(source) - } -} - -/// Pause one retry/debounce phase; subsequent passes remain asynchronous without wall-clock delay. -@MainActor -private final class RuleRuntimeDelayGate { - private let started: XCTestExpectation - private var didPause = false - private var continuation: CheckedContinuation? - - init(started: XCTestExpectation) { self.started = started } - - func wait() async { - guard !didPause else { - await Task.yield() - return - } - didPause = true - await withCheckedContinuation { - continuation = $0 - started.fulfill() - } - } - - func resume() { - continuation?.resume() - continuation = nil - } -} diff --git a/ShadowsocksX-NG2/Tests/Rules/RuleRuntimeDelayGateTests.swift b/ShadowsocksX-NG2/Tests/Rules/RuleRuntimeDelayGateTests.swift new file mode 100644 index 00000000..84fc92e6 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Rules/RuleRuntimeDelayGateTests.swift @@ -0,0 +1,59 @@ +import Testing +import XCTest + +@MainActor +struct RuleRuntimeDelayGateTests { + @Test func releaseBeforeWaitIsRemembered() async throws { + let gate = RuleRuntimeDelayGate(started: XCTestExpectation(description: "entered")) + gate.resume() + gate.resume() + try await gate.wait() + } + + @Test func missingReleaseTimesOutAndReportsFailure() async { + var reported = false + let gate = RuleRuntimeDelayGate( + started: XCTestExpectation(description: "entered"), onTimeout: { reported = true }) + await #expect(throws: TestWaitError.self) { + try await gate.wait(timeout: .milliseconds(20)) + } + #expect(reported) + gate.resume() + gate.resume() + } + + @Test func cancellationBeforeEntryDoesNotSuspend() async { + let gate = RuleRuntimeDelayGate(started: XCTestExpectation(description: "entered")) + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + try await gate.wait() + } + await #expect(throws: CancellationError.self) { try await task.value } + } + + @Test func cancellationWhileWaitingDrainsTheWait() async throws { + let entered = XCTestExpectation(description: "entered") + let gate = RuleRuntimeDelayGate(started: entered) + let task = Task { try await gate.wait() } + try await requireTestPhase([entered]) + task.cancel() + await #expect(throws: CancellationError.self) { try await task.value } + gate.resume() + } + + @Test func releaseAndCancellationCanRaceWithoutDoubleResume() async throws { + for _ in 0..<100 { + let entered = XCTestExpectation(description: "entered") + let gate = RuleRuntimeDelayGate(started: entered) + let task = Task { try await gate.wait() } + try await requireTestPhase([entered]) + let cancellation = Task.detached { task.cancel() } + gate.resume() + gate.resume() + do { try await task.value } catch { + #expect(error is CancellationError) + } + await cancellation.value + } + } +} diff --git a/ShadowsocksX-NG2/Tests/Settings/LANListeningWorkflowTests.swift b/ShadowsocksX-NG2/Tests/Settings/LANListeningWorkflowTests.swift index 162d7736..bd1b6d40 100644 --- a/ShadowsocksX-NG2/Tests/Settings/LANListeningWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/Settings/LANListeningWorkflowTests.swift @@ -175,13 +175,17 @@ struct LANListeningWorkflowTests { draft.isEnabled = true draft.mode = .allIPv4Interfaces let submission = Task { await workflow.saveLANListening(draft) } - await probe.waitUntilEntered() - #expect(workflow.isCommitting) - #expect(await workflow.saveProxyExceptions("example.com") == .rejected(.inProgress)) - probe.passThrough(.free) - probe.releaseGatedCalls() - #expect(await submission.value == .persisted) - #expect(!workflow.isCommitting) + try await withTestTask( + submission, release: { probe.releaseGatedCalls() }, + body: { + try await probe.waitUntilEntered() + #expect(workflow.isCommitting) + #expect(await workflow.saveProxyExceptions("example.com") == .rejected(.inProgress)) + probe.passThrough(.free) + probe.releaseGatedCalls() + #expect(await submission.value == .persisted) + #expect(!workflow.isCommitting) + }) } @Test(arguments: [ diff --git a/ShadowsocksX-NG2/Tests/Support/BoundedTestWait.swift b/ShadowsocksX-NG2/Tests/Support/BoundedTestWait.swift new file mode 100644 index 00000000..9d504ad9 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Support/BoundedTestWait.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +import XCTest + +enum TestWaitError: Error { + case timedOut(String) +} + +/// A phase failure must leave the test body before it can issue another gated command. +@MainActor +func requireTestPhase(_ expectations: [XCTestExpectation], timeout: TimeInterval = 5) async throws { + guard await XCTWaiter.fulfillment(of: expectations, timeout: timeout) == .completed else { + throw TestWaitError.timedOut(expectations.map(\.expectationDescription).joined(separator: ", ")) + } +} + +@MainActor +func requireTestCondition( + _ description: String, timeout: Duration = .seconds(5), condition: () -> Bool +) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while !condition() { + try Task.checkCancellation() + guard ContinuousClock.now < deadline else { throw TestWaitError.timedOut(description) } + try await Task.sleep(for: .milliseconds(10)) + } +} + +/// Release fixtures before joining a cancelled task, including on a phase failure. +@MainActor +func withTestTask( + _ task: Task, release: () -> Void, finish: () async -> Void = {}, + body: () async throws -> Void +) async throws { + do { + try await body() + } catch { + release() + task.cancel() + _ = await task.value + await finish() + throw error + } + release() + _ = await task.value + await finish() +} + +/// Broadcast release is remembered, including when cleanup runs before a worker enters. +final class TestBlockingGate: @unchecked Sendable { + private let group = DispatchGroup() + private let lock = NSLock() + private var released = false + private let onTimeout: @Sendable () -> Void + + init( + _ description: String = "test worker release", file: StaticString = #filePath, + line: UInt = #line, fileID: String = #fileID, column: Int = #column, + onTimeout: (@Sendable () -> Void)? = nil + ) { + 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( + fileID: fileID, filePath: String(describing: file), line: Int(line), column: column) + self.onTimeout = { Issue.record(message, sourceLocation: location) } + } 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 + } + return true + } + + func release() { + lock.lock() + let shouldRelease = !released + released = true + lock.unlock() + if shouldRelease { group.leave() } + } +} diff --git a/ShadowsocksX-NG2/Tests/Support/ProxyRuntimeFixture.swift b/ShadowsocksX-NG2/Tests/Support/ProxyRuntimeFixture.swift index b4d94f45..82b95ba5 100644 --- a/ShadowsocksX-NG2/Tests/Support/ProxyRuntimeFixture.swift +++ b/ShadowsocksX-NG2/Tests/Support/ProxyRuntimeFixture.swift @@ -254,14 +254,14 @@ enum ProxyRuntimeFixture { /// 线程,不占主 actor)。放行后恢复直通。 final class BlockingProbe: EndpointProbing, @unchecked Sendable { private let lock = NSLock() - private var gate: DispatchSemaphore? + private var gate: TestBlockingGate? private var started: XCTestExpectation? func arm(started: XCTestExpectation) { lock.lock() defer { lock.unlock() } precondition(gate == nil, "上一次 arm 尚未 release") - gate = DispatchSemaphore(value: 0) + gate = TestBlockingGate("runtime fixture release") self.started = started } @@ -271,7 +271,7 @@ enum ProxyRuntimeFixture { self.gate = nil self.started = nil lock.unlock() - gate?.signal() + gate?.release() } func probe(host: String, port: Int, timeout: TimeInterval) -> EndpointHealthProbe.Outcome { @@ -281,7 +281,7 @@ enum ProxyRuntimeFixture { lock.unlock() if let gate { started?.fulfill() - gate.wait() + _ = gate.wait() } return .reachable } @@ -295,7 +295,7 @@ enum ProxyRuntimeFixture { private let outcomes: [FirewallBlockStatus] private let blockOnCall: Int private let started: XCTestExpectation - private let gate = DispatchSemaphore(value: 0) + private let gate = TestBlockingGate("runtime fixture release") init(outcomes: [FirewallBlockStatus], blockOnCall: Int, started: XCTestExpectation) { precondition(!outcomes.isEmpty) @@ -304,7 +304,7 @@ enum ProxyRuntimeFixture { self.started = started } - func release() { gate.signal() } + func release() { gate.release() } func status(for executableURL: URL) -> FirewallBlockStatus { lock.lock() @@ -313,7 +313,7 @@ enum ProxyRuntimeFixture { lock.unlock() if call == blockOnCall { started.fulfill() - gate.wait() + _ = gate.wait() } return outcomes[min(call, outcomes.count) - 1] } diff --git a/ShadowsocksX-NG2/Tests/Support/RuleRuntimeDelayGate.swift b/ShadowsocksX-NG2/Tests/Support/RuleRuntimeDelayGate.swift new file mode 100644 index 00000000..db2a6778 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Support/RuleRuntimeDelayGate.swift @@ -0,0 +1,84 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// Pause one retry/debounce phase; subsequent passes remain asynchronous without wall-clock delay. +@MainActor +final class RuleRuntimeDelayGate { + private let started: XCTestExpectation + private let onTimeout: () -> Void + private var didPause = false + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + init( + started: XCTestExpectation, + onTimeout: @escaping () -> Void = { + XCTFail("Timed out waiting for rule runtime delay release") + } + ) { + self.started = started + self.onTimeout = onTimeout + (stream, continuation) = AsyncStream.makeStream() + } + + func wait(timeout: Duration = .seconds(10)) async throws { + try Task.checkCancellation() + guard !didPause else { + await Task.yield() + return + } + didPause = true + started.fulfill() + // AsyncStream ends its iterator on cancellation, so the timer race can drain both children. + do { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { [stream] in + for await _ in stream {} + try Task.checkCancellation() + } + group.addTask { + try await Task.sleep(for: timeout) + throw TestWaitError.timedOut("rule runtime delay release") + } + defer { group.cancelAll() } + try await group.next() + } + } catch let error as TestWaitError { + onTimeout() + throw error + } + } + + func resume() { + continuation.finish() + } +} + +/// Rule application can replace its task handle while an earlier save is finishing. +@MainActor +func withRuleApplicationTask( + _ task: Task, controller: ProxyRuntimeController, release: () -> Void, + body: () async throws -> Void +) async throws { + try await withTestTask( + task, release: release, + finish: { + controller.ruleApplicationTask?.cancel() + await controller.ruleApplicationTask?.value + }, body: body) +} + +final class PausedRuntimeRuleSource: @unchecked Sendable { + let started: XCTestExpectation + private let release = TestBlockingGate("rule source release") + init(started: XCTestExpectation) { self.started = started } + func resume() { release.release() } + func load(_ source: RulesSource) -> RuleSnapshot { + if source == .geolocationCN { + started.fulfill() + _ = release.wait() + } + return rulesFixture(source) + } +} diff --git a/ShadowsocksX-NG2/Tests/Support/SettingsWorkflowTestSupport.swift b/ShadowsocksX-NG2/Tests/Support/SettingsWorkflowTestSupport.swift index 83827b60..92ed78f1 100644 --- a/ShadowsocksX-NG2/Tests/Support/SettingsWorkflowTestSupport.swift +++ b/ShadowsocksX-NG2/Tests/Support/SettingsWorkflowTestSupport.swift @@ -191,18 +191,16 @@ final class FakeOccupancyProbe: PortOccupancyProbing, @unchecked Sendable { /// 新一轮之后落盘,验证过期结果不得覆盖。 final class GatedOccupancyProbe: PortOccupancyProbing, @unchecked Sendable { private let lock = NSLock() - private let gate = DispatchGroup() + private let gate = TestBlockingGate("occupancy probe release") private var isGated = true private var enteredCount = 0 - private let entry = AsyncGate() - private var released = false + private let entry = XCTestExpectation(description: "occupancy probe entered") private var gatedAnswer: PortOccupancy private var passThroughAnswer: PortOccupancy init(gatedAnswer: PortOccupancy, passThroughAnswer: PortOccupancy) { self.gatedAnswer = gatedAnswer self.passThroughAnswer = passThroughAnswer - gate.enter() } /// 已进入探测的调用数(按端口标识序,socks 先于 http)。 @@ -212,8 +210,8 @@ final class GatedOccupancyProbe: PortOccupancyProbing, @unchecked Sendable { return enteredCount } - func waitUntilEntered() async { - await entry.wait() + func waitUntilEntered() async throws { + try await requireTestPhase([entry]) } /// 之后的调用立即返回 `answer`;已进入且被扣住的调用仍持有各自应答。 @@ -227,24 +225,21 @@ final class GatedOccupancyProbe: PortOccupancyProbing, @unchecked Sendable { /// 放行全部被扣住的调用(广播门闩)。 func releaseGatedCalls() { lock.lock() - let shouldRelease = !released - released = true isGated = false lock.unlock() - if shouldRelease { - gate.leave() - } + gate.release() } func occupancy(for request: PortOccupancyProbeRequest) -> PortOccupancy { lock.lock() enteredCount += 1 + let firstEntry = enteredCount == 1 let gated = isGated let answer = gated ? gatedAnswer : passThroughAnswer lock.unlock() - entry.release() - if gated { - gate.wait() + if firstEntry { entry.fulfill() } + if gated, !gate.wait() { + return .unknown(detail: "Test fixture release timed out") } return answer } diff --git a/ShadowsocksX-NG2/Tests/TestSupport/BoundedTestWaitTests.swift b/ShadowsocksX-NG2/Tests/TestSupport/BoundedTestWaitTests.swift new file mode 100644 index 00000000..c95df1d1 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/TestSupport/BoundedTestWaitTests.swift @@ -0,0 +1,71 @@ +import Testing +import XCTest + +@MainActor +struct BoundedTestWaitTests { + @Test func missingPhaseStopsTheBodyAndDrainsItsTask() async throws { + let entered = XCTestExpectation(description: "worker entered") + let gate = RuleRuntimeDelayGate(started: entered) + var exited = false + var continued = false + let task = Task { + try? await gate.wait() + exited = true + } + try await requireTestPhase([entered]) + await #expect(throws: TestWaitError.self) { + try await withTestTask( + task, release: { gate.resume() }, + body: { + try await requireTestPhase( + [XCTestExpectation(description: "phase never reached")], timeout: 0.02) + continued = true + }) + } + #expect(!continued) + #expect(exited) + } + + @Test func missingConditionHasADeadline() async { + await #expect(throws: TestWaitError.self) { + try await requireTestCondition("never true", timeout: .milliseconds(20)) { false } + } + } + + @Test func cancelledConditionWaitStops() async { + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + try await requireTestCondition("never true") { false } + } + 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 blockingGateRemembersEarlyAndRepeatedRelease() { + let gate = TestBlockingGate() + gate.release() + gate.release() + #expect(gate.wait(timeout: 0)) + } + + @Test func blockingGateReleasesEveryWorker() async throws { + let gate = TestBlockingGate() + let entered = XCTestExpectation(description: "workers entered") + entered.expectedFulfillmentCount = 4 + let workers = (0..<4).map { _ in + Task.detached { + entered.fulfill() + return gate.wait() + } + } + try await requireTestPhase([entered]) + gate.release() + gate.release() + for worker in workers { #expect(await worker.value) } + } +}