From feb25de460d67a944a28f2b2ae43598e01af1fc1 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sun, 4 Oct 2026 01:05:10 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B3=A8=E5=85=A5=E8=A7=84=E5=88=99?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E4=B8=8E=E5=81=A5=E5=BA=B7=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E7=AD=89=E5=BE=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProxyRuntimeController+CustomRules.swift | 2 +- .../App/ProxyRuntimeController+Health.swift | 2 +- .../App/ProxyRuntimeController.swift | 10 ++ .../Tests/CustomRuleControllerTests.swift | 8 + .../Tests/ProxyRuntimeControllerTests.swift | 8 + .../Tests/RuleEnablementControllerTests.swift | 165 +++++++++++------- 6 files changed, 130 insertions(+), 65 deletions(-) diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift index 07b039c0..945219b1 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+CustomRules.swift @@ -120,7 +120,7 @@ extension ProxyRuntimeController { defer { self.ruleApplicationTask = nil } while !Task.isCancelled { let generation = self.ruleApplicationGeneration - do { try await Task.sleep(for: .milliseconds(150)) } catch { return } + do { try await self.ruleApplicationDelay() } catch { return } guard generation == self.ruleApplicationGeneration else { continue } await self.applySavedRuleDocument() if generation == self.ruleApplicationGeneration { return } diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Health.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Health.swift index 02cd3fa8..149b9225 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Health.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Health.swift @@ -65,7 +65,7 @@ extension ProxyRuntimeController { case .agentLost: return await reportAgentLost(preserveProxyOnFailure: preserveProxyOnFailure) } - try? await Task.sleep(nanoseconds: 200_000_000) + try? await launchHealthRetryDelay() } return await reportLaunchHealthTimeout( LaunchHealthTimeout( diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift index 77c72f87..a3e410eb 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift @@ -94,6 +94,8 @@ final class ProxyRuntimeController: ObservableObject { let appBundle: Bundle let firewallPollIntervalNanoseconds: UInt64 let launchHealthTimeoutSeconds: TimeInterval + let launchHealthRetryDelay: () async throws -> Void + let ruleApplicationDelay: () async throws -> Void /// 注册清单漂移重注时,注销与重注的间隔(launchd 对节流中 job 的移除异步)。 let systemProxyHealthPollIntervalNanoseconds: UInt64 let helperRefreshDelayNanoseconds: UInt64 @@ -135,6 +137,12 @@ final class ProxyRuntimeController: ObservableObject { firewallExecutableURLs: [URL]? = nil, firewallPollIntervalNanoseconds: UInt64 = 2_000_000_000, launchHealthTimeoutSeconds: TimeInterval = 15, + launchHealthRetryDelay: @escaping () async throws -> Void = { + try await Task.sleep(for: .milliseconds(200)) + }, + ruleApplicationDelay: @escaping () async throws -> Void = { + try await Task.sleep(for: .milliseconds(150)) + }, systemProxyHealthPollIntervalNanoseconds: UInt64 = 2_000_000_000, helperRefreshDelayNanoseconds: UInt64 = 15_000_000_000, sendSignal: @escaping @Sendable (Int32, Int32) -> Int32 = { kill($0, $1) }, @@ -170,6 +178,8 @@ final class ProxyRuntimeController: ObservableObject { self.firewallExecutableURLs = firewallExecutableURLs ?? Self.defaultFirewallExecutableURLs self.firewallPollIntervalNanoseconds = firewallPollIntervalNanoseconds self.launchHealthTimeoutSeconds = launchHealthTimeoutSeconds + self.launchHealthRetryDelay = launchHealthRetryDelay + self.ruleApplicationDelay = ruleApplicationDelay self.systemProxyHealthPollIntervalNanoseconds = systemProxyHealthPollIntervalNanoseconds self.helperRefreshDelayNanoseconds = helperRefreshDelayNanoseconds self.sendSignal = sendSignal diff --git a/ShadowsocksX-NG2/Tests/CustomRuleControllerTests.swift b/ShadowsocksX-NG2/Tests/CustomRuleControllerTests.swift index 5fba3b5b..d719792d 100644 --- a/ShadowsocksX-NG2/Tests/CustomRuleControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/CustomRuleControllerTests.swift @@ -19,6 +19,12 @@ extension ProxyRuntimeControllerTests { settings: ProxySettings? = nil, proxyMode: ProxyMode? = nil, launchHealthTimeoutSeconds: TimeInterval = 0.05, + launchHealthRetryDelay: @escaping () async throws -> Void = { + try await Task.sleep(for: .milliseconds(200)) + }, + ruleApplicationDelay: @escaping () async throws -> Void = { + try await Task.sleep(for: .milliseconds(150)) + }, ruleSnapshots: BuiltinRuleSnapshots? = nil ) -> ProxyRuntimeController { makeController( @@ -28,6 +34,8 @@ extension ProxyRuntimeControllerTests { ?? ProxySettings(listen: ActivationFixture.listen, agentEnabled: true), proxyMode: proxyMode, launchHealthTimeoutSeconds: launchHealthTimeoutSeconds, + launchHealthRetryDelay: launchHealthRetryDelay, + ruleApplicationDelay: ruleApplicationDelay, customRuleStore: store, ruleSnapshots: ruleSnapshots) } diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift index d82afff6..b97a0918 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift @@ -122,6 +122,12 @@ class ProxyRuntimeControllerTests: XCTestCase { firewallPollIntervalNanoseconds: UInt64 = 1_000_000, // 生产默认 15 秒健康窗;FakeProbe 结果确定,短窗口走同一超时呈现路径。 launchHealthTimeoutSeconds: TimeInterval = 0.05, + launchHealthRetryDelay: @escaping () async throws -> Void = { + try await Task.sleep(for: .milliseconds(200)) + }, + ruleApplicationDelay: @escaping () async throws -> Void = { + try await Task.sleep(for: .milliseconds(150)) + }, // 漂移重注的注销-重注间隔与用例无关,缩短保持套件快速。 helperRefreshDelayNanoseconds: UInt64 = 10_000_000, processIsAlive: @escaping @Sendable (Int32) -> Bool = { $0 == 42 }, @@ -170,6 +176,8 @@ class ProxyRuntimeControllerTests: XCTestCase { firewallExecutableURLs: firewallExecutableURLs, firewallPollIntervalNanoseconds: firewallPollIntervalNanoseconds, launchHealthTimeoutSeconds: launchHealthTimeoutSeconds, + launchHealthRetryDelay: launchHealthRetryDelay, + ruleApplicationDelay: ruleApplicationDelay, helperRefreshDelayNanoseconds: helperRefreshDelayNanoseconds, sendSignal: { [signals] pid, number in signals!.send(pid, number) }, processIsAlive: processIsAlive) diff --git a/ShadowsocksX-NG2/Tests/RuleEnablementControllerTests.swift b/ShadowsocksX-NG2/Tests/RuleEnablementControllerTests.swift index 8c234c5e..749e8ed3 100644 --- a/ShadowsocksX-NG2/Tests/RuleEnablementControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/RuleEnablementControllerTests.swift @@ -7,25 +7,49 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { BuiltinRuleSnapshots(loader: ProxyRuntimeFixture.controlFlowRuleSnapshot) } + private func makeRuleEnablementController( + store: CustomRuleStore, + settings: ProxySettings, + proxyMode: ProxyMode? = nil, + launchHealthTimeoutSeconds: TimeInterval = 0.05, + ruleSnapshots: BuiltinRuleSnapshots? = nil, + launchHealthRetryDelay: @escaping () async throws -> Void = { + try await Task.sleep(for: .milliseconds(1)) + }, + ruleApplicationDelay: @escaping () async throws -> Void = { await Task.yield() } + ) -> ProxyRuntimeController { + makeControllerWithCustomRules( + store: store, settings: settings, proxyMode: proxyMode, + launchHealthTimeoutSeconds: launchHealthTimeoutSeconds, + launchHealthRetryDelay: launchHealthRetryDelay, + ruleApplicationDelay: ruleApplicationDelay, + ruleSnapshots: ruleSnapshots) + } + func testRapidRuleSavesCoalesceIntoOneLatestACLDeployment() async throws { let seeded = try makeSeededCatalog() let (store, _) = try makeCustomRuleStore() let first = CustomRule(action: .direct, match: .domainExact("first-save.example")) let latest = CustomRule(action: .direct, match: .domainExact("latest-save.example")) - let controller = makeControllerWithCustomRules( + let applicationStarted = expectation(description: "rule application paused") + let applicationGate = RuleRuntimeDelayGate(started: applicationStarted) + defer { applicationGate.resume() } + let controller = makeRuleEnablementController( store: store, settings: ProxySettings( listen: ActivationFixture.listen, preferredMode: .rule, agentEnabled: true), - proxyMode: .rule) + proxyMode: .rule, ruleApplicationDelay: { 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)) @@ -38,11 +62,15 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { let (store, _) = try makeCustomRuleStore() let first = CustomRule(action: .direct, match: .domainExact("in-flight.example")) let latest = CustomRule(action: .direct, match: .domainExact("after-flight.example")) - let controller = makeControllerWithCustomRules( + let healthWaiting = expectation(description: "health retry paused") + let healthGate = RuleRuntimeDelayGate(started: healthWaiting) + defer { healthGate.resume() } + let controller = makeRuleEnablementController( store: store, settings: ProxySettings( listen: ActivationFixture.listen, preferredMode: .rule, agentEnabled: true), - proxyMode: .rule, launchHealthTimeoutSeconds: 0.3) + proxyMode: .rule, launchHealthTimeoutSeconds: 0.3, + launchHealthRetryDelay: { await healthGate.wait() }) try await controller.activate(seeded.server) let runtimeStore = RuntimeFileStore(fileURL: runtime.contract) let started = expectation(description: "first deployment awaiting receipt") @@ -60,12 +88,13 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { } let one = await controller.commitRuleDocument(CustomRuleDocument(rules: [first])) XCTAssertEqual(one.outcome, .saved) - await fulfillment(of: [started], timeout: 3) + 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) @@ -81,7 +110,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { let started = expectation(description: "new source preparation") let loader = PausedRuntimeRuleSource(started: started) defer { loader.release.signal() } - let controller = makeControllerWithCustomRules( + let controller = makeRuleEnablementController( store: store, settings: ProxySettings( listen: ActivationFixture.listen, preferredMode: .global, agentEnabled: true), @@ -112,7 +141,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { let started = expectation(description: "background source preparation") let loader = PausedRuntimeRuleSource(started: started) defer { loader.release.signal() } - let controller = makeControllerWithCustomRules( + let controller = makeRuleEnablementController( store: store, settings: ProxySettings( listen: ActivationFixture.listen, preferredMode: .global, agentEnabled: true), @@ -138,7 +167,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { let snapshots = BuiltinRuleSnapshots(loader: { loader.load($0) }) let settings = ProxySettings( listen: ActivationFixture.listen, preferredMode: .global, agentEnabled: true) - let controller = makeControllerWithCustomRules( + let controller = makeRuleEnablementController( store: store, settings: settings, proxyMode: .global, ruleSnapshots: snapshots) try await controller.activate(seeded.server) let modeSwitch = Task { await controller.setProxyMode(.rule) } @@ -173,7 +202,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { let started = expectation(description: "background source preparation") let loader = PausedRuntimeRuleSource(started: started) defer { loader.release.signal() } - let controller = makeControllerWithCustomRules( + let controller = makeRuleEnablementController( store: store, settings: ProxySettings( listen: ActivationFixture.listen, preferredMode: .global, agentEnabled: true), @@ -190,41 +219,9 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { RuntimeFileStore(fileURL: runtime.contract).loadDocument()?.aclRuntime?.summary, "direct") } - func testBatchDisableRestartsOnceWithoutRestoringOmittedChinaCandidate() async throws { - let seeded = try makeSeededCatalog() - let (store, _) = try makeCustomRuleStore() - let settings = ProxySettings( - listen: ActivationFixture.listen, preferredMode: .rule, - ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true) - let controller = makeControllerWithCustomRules( - store: store, settings: settings, proxyMode: .rule, - ruleSnapshots: BuiltinRuleSnapshots(bundle: AppArtifact.bundle)) - try await controller.activate(seeded.server) - let runtimeStore = RuntimeFileStore(fileURL: runtime.contract) - let before = agent.unregisterCount - let cnSuffix = RuleIdentity(action: .direct, match: .domainSuffix("cn")) - let absent = RuleIdentity(action: .direct, match: .domainExact("absent.example")) - let outcome = await controller.updateRuleDocument( - CustomRuleDocument(rules: [], disabledIdentities: [cnSuffix, absent])) - XCTAssertEqual(outcome, .saved) - await controller.ruleApplicationTask?.value - XCTAssertEqual(agent.unregisterCount, before + 1) - let content = try activeACLContent(runtimeStore) - XCTAssertFalse(content.split(separator: "\n").contains("||cn")) - let snapshot = try BuiltinRuleCatalog.loadGeolocationCN(from: AppArtifact.bundle) - XCTAssertGreaterThan(snapshot.lossReport.absorbedCount, 0) - XCTAssertFalse(content.split(separator: "\n").contains("||baidu.cn")) - let noOpBefore = agent.unregisterCount - let unchanged = await controller.updateRuleDocument( - CustomRuleDocument(rules: [], disabledIdentities: [cnSuffix])) - XCTAssertEqual(unchanged, .saved) - await controller.ruleApplicationTask?.value - XCTAssertEqual(agent.unregisterCount, noOpBefore, "Absent identities change only persistence") - } - func testOffSaveUsesOwnedDocumentAndNewSessionRejectsCorruption() async throws { let (store, _) = try makeCustomRuleStore() - let controller = makeControllerWithCustomRules( + let controller = makeRuleEnablementController( store: store, settings: ProxySettings(listen: ActivationFixture.listen, agentEnabled: false)) let identity = RuleIdentity(action: .proxy, match: .domainExact("saved.example")) let outcome = await controller.updateRuleDocument( @@ -235,7 +232,7 @@ final class RuleEnablementControllerTests: ProxyRuntimeControllerTests { XCTAssertEqual(try store.loadDocument().disabledIdentities, [identity]) let broken = Data("broken".utf8) try broken.write(to: store.fileURL) - let newSession = makeControllerWithCustomRules( + let newSession = makeRuleEnablementController( store: store, settings: ProxySettings(listen: ActivationFixture.listen, agentEnabled: false)) let rejected = await newSession.updateRuleDocument(CustomRuleDocument(rules: [])) XCTAssertEqual(rejected, .persistenceFailed) @@ -262,36 +259,36 @@ extension RuleEnablementControllerTests { let settings = ProxySettings( listen: ActivationFixture.listen, preferredMode: .rule, ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true) - let controller = makeControllerWithCustomRules( + let recoveryWaiting = expectation(description: "recovery health retry paused") + let recoveryGate = RuleRuntimeDelayGate(started: recoveryWaiting) + defer { recoveryGate.resume() } + var recoveryIsRunning = false + let controller = makeRuleEnablementController( store: store, settings: settings, proxyMode: .rule, - launchHealthTimeoutSeconds: 0.2) + launchHealthTimeoutSeconds: 0.05, + launchHealthRetryDelay: { + if recoveryIsRunning { + await recoveryGate.wait() + } else { + try await Task.sleep(for: .milliseconds(1)) + } + }) try await controller.activate(seeded.server) let runtimeStore = RuntimeFileStore(fileURL: runtime.contract) - let recoveryStarted = expectation(description: "recovery awaiting health") let initialRegistrations = agent.registerCount - agent.onRegister = { [agent] in - if agent?.registerCount == initialRegistrations + 2 { - recoveryStarted.fulfill() - } - if (agent?.registerCount ?? 0) <= initialRegistrations + 2 { - try? FileManager.default.removeItem(at: runtimeStore.runtimeStatusFileURL) - } else if let document = runtimeStore.loadDocument() { - try? runtimeStore.writeRuntimeReceipt(for: document, processID: 42) - } + configureRecoveryReceipts(runtimeStore, initialRegistrations: initialRegistrations) { + recoveryIsRunning = true } - let pending = Task { - await controller.updateRuleDocument( - CustomRuleDocument( - rules: [rule], - disabledIdentities: [rule.identity])) - } - await fulfillment(of: [recoveryStarted], timeout: 5) + let disabledDocument = CustomRuleDocument(rules: [rule], disabledIdentities: [rule.identity]) + let pending = Task { await controller.updateRuleDocument(disabledDocument) } + await fulfillment(of: [recoveryWaiting], timeout: 5) if switchMode { await controller.setProxyMode(.global) } else { await controller.setAgentEnabled(false) } let registrations = agent.registerCount + recoveryGate.resume() let outcome = await pending.value XCTAssertEqual(outcome, .saved) await controller.ruleApplicationTask?.value @@ -307,6 +304,21 @@ extension RuleEnablementControllerTests { } } + private func configureRecoveryReceipts( + _ runtimeStore: RuntimeFileStore, + initialRegistrations: Int, + onRecovery: @escaping () -> Void + ) { + agent.onRegister = { [agent] in + if agent?.registerCount == initialRegistrations + 2 { onRecovery() } + if (agent?.registerCount ?? 0) <= initialRegistrations + 2 { + try? FileManager.default.removeItem(at: runtimeStore.runtimeStatusFileURL) + } else if let document = runtimeStore.loadDocument() { + try? runtimeStore.writeRuntimeReceipt(for: document, processID: 42) + } + } + } + func testFailedDisableAndFailedRuntimeRecoveryReportsFailure() async throws { let seeded = try makeSeededCatalog() let (store, _) = try makeCustomRuleStore() @@ -315,7 +327,7 @@ extension RuleEnablementControllerTests { let settings = ProxySettings( listen: ActivationFixture.listen, preferredMode: .rule, ruleDefaultAction: .proxyWhenUnmatched, agentEnabled: true) - let controller = makeControllerWithCustomRules( + let controller = makeRuleEnablementController( store: store, settings: settings, proxyMode: .rule) try await controller.activate(seeded.server) let runtimeStore = RuntimeFileStore(fileURL: runtime.contract) @@ -346,3 +358,30 @@ private final class PausedRuntimeRuleSource: @unchecked Sendable { 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 + } +}