mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:14:51 +00:00
Initial multi-server checks can consume the entire five-second listener budget before sslocal binds its inbound ports. Allow 15 seconds for listeners and 30 seconds for GUI startup health confirmation. Include endpoint readiness diagnostics and a delayed-start regression.
339 lines
15 KiB
Swift
339 lines
15 KiB
Swift
import Combine
|
|
import XCTest
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
/// 代理运行时控制器:以替身注入 LaunchAgent 与端点探测,验证
|
|
/// agent 开关与系统代理开关的独立语义、目录重展开消费与 GUI 重同步重合(不
|
|
/// 触真实 SMAppService/launchd/SystemConfiguration)。
|
|
@MainActor
|
|
class ProxyRuntimeControllerTests: XCTestCase {
|
|
var controlPreferences: ProxyControlPreferences!
|
|
var runtime: ProxyRuntimeFixture.TemporaryRuntime!
|
|
var catalogFileURL: URL!
|
|
var activationFileURL: URL!
|
|
var agent: ProxyRuntimeFixture.FakeLaunchAgent!
|
|
var systemProxy: ProxyRuntimeFixture.FakeSystemProxy!
|
|
var systemProxyHelper: ProxyRuntimeFixture.FakeSystemProxyHelperService!
|
|
var systemProxyNetworkChangeMonitor: ProxyRuntimeFixture.FakeSystemProxyNetworkChangeMonitor!
|
|
var signals: SignalRecorder!
|
|
|
|
/// SIGUSR1 投递记录缝。
|
|
final class SignalRecorder: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private(set) var records: [(pid: Int32, signal: Int32)] = []
|
|
/// SIGUSR1 送达后模拟 wrapper 热重载刷新回执。
|
|
var reloadReceipt: (() -> Void)?
|
|
/// wrapper 进程存活位:生产中注销 LaunchAgent 后 wrapper 随 launchd 停止
|
|
/// 消亡,pid 文件留存至 deleteRuntimeFiles、死亡靠 kill(_, 0) 探测;
|
|
/// 重注册即拉起新进程。不置死则停止协议会空等退出超时上限。
|
|
private var wrapperAlive = true
|
|
|
|
var signalsSent: [(pid: Int32, signal: Int32)] {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return records
|
|
}
|
|
|
|
func record(_ pid: Int32, _ signal: Int32) {
|
|
lock.lock()
|
|
records.append((pid, signal))
|
|
lock.unlock()
|
|
}
|
|
|
|
/// 注销 LaunchAgent:对应 wrapper 进程消亡。
|
|
func terminateWrapper() {
|
|
lock.lock()
|
|
wrapperAlive = false
|
|
lock.unlock()
|
|
}
|
|
|
|
/// 重新注册:拉起新 wrapper 进程。
|
|
func relaunchWrapper() {
|
|
lock.lock()
|
|
wrapperAlive = true
|
|
lock.unlock()
|
|
}
|
|
|
|
func send(_ pid: Int32, _ signal: Int32) -> Int32 {
|
|
record(pid, signal)
|
|
if signal == SIGUSR1 {
|
|
reloadReceipt?()
|
|
}
|
|
// kill(_, 0) 判活语义:只对夹具约定的存活 pid(42 与本测试进程)报告
|
|
// 在跑;陈旧残留 pid 返回 ESRCH,停止协议才不必空等退出超时上限。
|
|
if signal == 0 {
|
|
lock.lock()
|
|
let alive = wrapperAlive
|
|
lock.unlock()
|
|
return (pid == 42 && alive) || pid == ProcessInfo.processInfo.processIdentifier
|
|
? 0 : Int32(ESRCH)
|
|
}
|
|
return 0
|
|
}
|
|
}
|
|
|
|
override func setUp() async throws {
|
|
try await super.setUp()
|
|
controlPreferences = makeTestProxyControlPreferences()
|
|
runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
|
|
catalogFileURL = runtime.directory.appendingPathComponent("catalog.json")
|
|
activationFileURL = runtime.directory.appendingPathComponent("activation.json")
|
|
|
|
agent = ProxyRuntimeFixture.FakeLaunchAgent()
|
|
systemProxy = ProxyRuntimeFixture.FakeSystemProxy()
|
|
systemProxyHelper = ProxyRuntimeFixture.FakeSystemProxyHelperService()
|
|
systemProxyNetworkChangeMonitor = ProxyRuntimeFixture.FakeSystemProxyNetworkChangeMonitor()
|
|
signals = SignalRecorder()
|
|
// 注销即 wrapper 消亡:停止协议的退出等待据 kill(_, 0) 放行,不空等超时。
|
|
agent.onUnregister = { [weak self] in self?.signals?.terminateWrapper() }
|
|
}
|
|
|
|
override func tearDown() async throws {
|
|
try? FileManager.default.removeItem(at: runtime.directory)
|
|
try await super.tearDown()
|
|
}
|
|
|
|
/// 建一个含单台服务器的目录并落盘(服务器密码进内存凭据存储)。
|
|
func makeSeededCatalog() throws -> (catalog: ConfigurationCatalog, server: NodeID) {
|
|
var catalog = ConfigurationCatalog()
|
|
let server = try ActivationFixture.addPlainServer(
|
|
"香港 01", in: &catalog)
|
|
try makeTestCatalogStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog))
|
|
return (catalog, server)
|
|
}
|
|
|
|
/// Subclasses can choose small rule inputs without changing packaged-data suites.
|
|
func makeDefaultRuleSnapshots() -> BuiltinRuleSnapshots {
|
|
BuiltinRuleSnapshots(bundle: AppArtifact.bundle)
|
|
}
|
|
|
|
func makeController(
|
|
probe: EndpointProbing,
|
|
agentStatus: LaunchAgentStatus = .notRegistered,
|
|
listen: ProxyListeningConfiguration = ActivationFixture.listen,
|
|
listeningStore: ProxyListeningStoring? = nil,
|
|
settings: ProxyRuntimeSettings? = nil,
|
|
settingsRestore: RestoredProxyRuntimeSettings? = nil,
|
|
proxyMode: ProxyMode? = .rule,
|
|
systemProxy: SystemProxyControlling? = nil,
|
|
networkChangeMonitor: SystemProxyNetworkChangeMonitoring? = nil,
|
|
firewallChecker: FirewallStatusChecking = ProxyRuntimeFixture.FakeFirewallChecker(),
|
|
firewallExecutableURLs: [URL] = [URL(fileURLWithPath: "/bundle/Helpers/sslocal")],
|
|
firewallPollIntervalNanoseconds: UInt64 = 1_000_000,
|
|
// 生产默认 30 秒健康窗;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 },
|
|
customRuleStore: CustomRuleStore? = nil,
|
|
ruleSnapshots: BuiltinRuleSnapshots? = nil
|
|
) -> ProxyRuntimeController {
|
|
agent.setStatus(agentStatus)
|
|
let runtimeFileStore = RuntimeFileStore(fileURL: runtime.contract)
|
|
agent.onRegister = { [runtimeFileStore, signals] in
|
|
// 重注册=拉起新 wrapper 进程(注销时已随 LaunchAgent 置死)。
|
|
signals?.relaunchWrapper()
|
|
try? runtimeFileStore.writePublishedRuntimeReceipt(processID: 42)
|
|
}
|
|
// SIGUSR1 模拟 wrapper 热重载后刷新回执(真实 wrapper 会如此)。
|
|
signals.reloadReceipt = { [runtimeFileStore] in
|
|
try? runtimeFileStore.writePublishedRuntimeReceipt(processID: 42)
|
|
}
|
|
let restored =
|
|
settingsRestore
|
|
?? RestoredProxyRuntimeSettings(
|
|
// 控制器行为用例默认「用户已打开 Agent」;出厂默认 off 由 ProxyRuntimeSettingsTests 锁定。
|
|
settings: settings ?? ProxyRuntimeSettings(listen: listen, agentEnabled: true),
|
|
unreadableError: nil)
|
|
// Fixture snapshots represent previously saved intent, matching production bootstrap.
|
|
controlPreferences.preferredMode = restored.settings.preferredMode
|
|
controlPreferences.rulePreset = restored.settings.rulePreset
|
|
controlPreferences.agentEnabled = restored.settings.agentEnabled
|
|
controlPreferences.systemProxyEnabled = restored.settings.systemProxyEnabled
|
|
controlPreferences.proxyExceptions = restored.settings.proxyExceptions
|
|
return ProxyRuntimeController(
|
|
controlPreferences: controlPreferences,
|
|
catalogSnapshotReader: ProxyRuntimeFixture.catalogSnapshotReader(at: catalogFileURL),
|
|
activationFileStore: ActivationStateFileStore(fileURL: activationFileURL),
|
|
runtimeFileStore: runtimeFileStore,
|
|
plugins: ActivationFixture.plugins,
|
|
listeningStore: listeningStore ?? InMemoryProxyListeningStore(),
|
|
customRuleStore: customRuleStore
|
|
?? CustomRuleStore(fileURL: runtime.directory.appendingPathComponent("custom-rules.json")),
|
|
appBundle: AppArtifact.bundle,
|
|
ruleSnapshots: ruleSnapshots ?? makeDefaultRuleSnapshots(),
|
|
settingsRestore: restored,
|
|
agent: agent,
|
|
probe: probe,
|
|
systemProxy: systemProxy ?? self.systemProxy,
|
|
systemProxyHelper: systemProxyHelper ?? self.systemProxyHelper,
|
|
systemProxyNetworkChangeMonitor: networkChangeMonitor ?? systemProxyNetworkChangeMonitor,
|
|
proxyMode: proxyMode,
|
|
firewallChecker: firewallChecker,
|
|
firewallExecutableURLs: firewallExecutableURLs,
|
|
firewallPollIntervalNanoseconds: firewallPollIntervalNanoseconds,
|
|
launchHealthTimeoutSeconds: launchHealthTimeoutSeconds,
|
|
launchHealthRetryDelay: launchHealthRetryDelay,
|
|
ruleApplicationDelay: ruleApplicationDelay,
|
|
helperRefreshDelayNanoseconds: helperRefreshDelayNanoseconds,
|
|
sendSignal: { [signals] pid, number in signals!.send(pid, number) },
|
|
processIsAlive: processIsAlive)
|
|
}
|
|
|
|
}
|
|
|
|
/// Concrete suite; the shared fixture must not contain inherited test methods.
|
|
final class ProxyRuntimeControllerCoreTests: ProxyRuntimeControllerTests {
|
|
override func makeDefaultRuleSnapshots() -> BuiltinRuleSnapshots {
|
|
BuiltinRuleSnapshots(loader: ProxyRuntimeFixture.controlFlowRuleSnapshot)
|
|
}
|
|
|
|
// MARK: Agent 开关与首次默认
|
|
|
|
func testActivateWhileAgentOffPublishesActiveTargetIDWithoutDeploying() async throws {
|
|
let seeded = try makeSeededCatalog()
|
|
let controller = makeController(
|
|
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
|
|
settings: ProxyRuntimeSettings(listen: ActivationFixture.listen, agentEnabled: false))
|
|
var observed: NodeID?
|
|
let cancellable = controller.factChanges.changes.sink { @MainActor _ in
|
|
observed = controller.activeTargetID
|
|
}
|
|
defer { cancellable.cancel() }
|
|
|
|
try await controller.activate(seeded.server)
|
|
// 事实通道在主队列轮末冲洗:让排队的冲洗先于断言运行。
|
|
await Task.yield()
|
|
|
|
XCTAssertEqual(controller.activeTargetID, seeded.server)
|
|
XCTAssertEqual(observed, seeded.server, "agent 关闭路径的激活也要发布目标变更")
|
|
XCTAssertEqual(controller.state, .off, "agent 意图关闭时仅选择目标,不部署")
|
|
XCTAssertEqual(agent.registerCount, 0)
|
|
}
|
|
|
|
func testActivateThenEnableWritesContractRegistersAndReachesRunning() async throws {
|
|
let seeded = try makeSeededCatalog()
|
|
let probe = ProxyRuntimeFixture.FakeProbe.reachable()
|
|
let controller = makeController(probe: probe)
|
|
|
|
try await controller.activate(seeded.server)
|
|
await controller.setAgentEnabled(true)
|
|
|
|
XCTAssertEqual(controller.state, .running)
|
|
XCTAssertEqual(agent.registerCount, 1)
|
|
let persisted = try ActivationStateFileStore(fileURL: activationFileURL).loadActiveTargetID()
|
|
XCTAssertEqual(persisted, seeded.server, "活动目标已持久化")
|
|
let onDisk = try XCTUnwrap(
|
|
RuntimeFileStore(fileURL: runtime.contract).loadDocument())
|
|
XCTAssertEqual(onDisk.servers.count, 1)
|
|
XCTAssertEqual(onDisk.socksPort, ActivationFixture.listen.socksPort)
|
|
XCTAssertEqual(onDisk.servers.first?.password, "pw-香港 01", "凭据已解析进文档")
|
|
XCTAssertTrue(
|
|
systemProxy.applied.isEmpty, "系统代理意图默认关闭:agent 运行也不写系统设置")
|
|
XCTAssertEqual(probe.ports, [11086, 11087], "健康门先探测 SOCKS 和 HTTP 入站")
|
|
}
|
|
|
|
func testEffectiveRuntimeListenerProcessIDRequiresCurrentLiveReceipt() throws {
|
|
let controller = makeController(
|
|
probe: ProxyRuntimeFixture.FakeProbe.reachable(), processIsAlive: { $0 == 42 })
|
|
let document = SslocalRuntimeDocument(servers: [], listen: ActivationFixture.listen)
|
|
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
|
|
try runtimeStore.write(document)
|
|
try runtimeStore.writeRuntimeReceipt(for: document, processID: 42)
|
|
controller.runtimeObservation = RuntimeDeploymentFacts(document)
|
|
controller.state = .running
|
|
|
|
XCTAssertEqual(controller.effectiveRuntimeListenerProcessID, 42)
|
|
|
|
let staleReceipt = RuntimeDeploymentReceipt(processID: 42, deploymentID: UUID().uuidString)
|
|
try AtomicFileWriter.write(
|
|
JSONEncoder().encode(staleReceipt), to: runtimeStore.runtimeStatusFileURL)
|
|
XCTAssertNil(controller.effectiveRuntimeListenerProcessID, "回执必须匹配当前运行契约")
|
|
|
|
try runtimeStore.writeRuntimeReceipt(for: document, processID: 42)
|
|
controller.state = .off
|
|
XCTAssertNil(controller.effectiveRuntimeListenerProcessID, "已停止 runtime 不得拥有监听器")
|
|
}
|
|
|
|
func testSwitchingGlobalAndRuleModesIsImmediateAndRestoresOnAgentOff() async throws {
|
|
let seeded = try makeSeededCatalog()
|
|
let controller = makeController(
|
|
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
|
|
settings: ProxyRuntimeSettings(
|
|
listen: ActivationFixture.listen,
|
|
preferredMode: .global,
|
|
agentEnabled: true,
|
|
systemProxyEnabled: true),
|
|
proxyMode: .global)
|
|
|
|
try await controller.activate(seeded.server)
|
|
await controller.setAgentEnabled(true)
|
|
XCTAssertEqual(
|
|
systemProxy.applied,
|
|
[
|
|
SystemProxyConfiguration(
|
|
socks: .init(host: "127.0.0.1", port: 11086),
|
|
http: .init(host: "127.0.0.1", port: 11087),
|
|
https: .init(host: "127.0.0.1", port: 11087),
|
|
exceptions: FixedLocalProxyRanges.systemProxyExceptions(
|
|
including: ProxyRuntimeSettings().proxyExceptionList))
|
|
])
|
|
|
|
await controller.setProxyMode(.rule)
|
|
XCTAssertEqual(controller.state, .running)
|
|
XCTAssertEqual(systemProxy.applied.count, 2)
|
|
XCTAssertEqual(
|
|
systemProxy.applied.last,
|
|
SystemProxyConfiguration(
|
|
socks: .init(host: "127.0.0.1", port: 11086),
|
|
http: .init(host: "127.0.0.1", port: 11087),
|
|
https: .init(host: "127.0.0.1", port: 11087),
|
|
exceptions: FixedLocalProxyRanges.systemProxyExceptions(
|
|
including: ProxyRuntimeSettings().proxyExceptionList)))
|
|
|
|
await controller.setAgentEnabled(false)
|
|
XCTAssertEqual(controller.state, .off)
|
|
XCTAssertEqual(systemProxy.clearCount, 1, "关闭 agent 清除已应用的系统代理")
|
|
}
|
|
|
|
func testEnableWhenProbeNeverSucceedsPresentsFailureNamingEndpointAndPort() async throws {
|
|
let seeded = try makeSeededCatalog()
|
|
let controller = makeController(
|
|
probe: ProxyRuntimeFixture.FakeProbe.refusing(), agentStatus: .notRegistered)
|
|
|
|
try await controller.activate(seeded.server)
|
|
await controller.setAgentEnabled(true)
|
|
|
|
guard case .launchFailed(.localEndpoint(_, _, let port, let cause)) = controller.state else {
|
|
XCTFail("应呈现启动失败,实际 \(controller.state)")
|
|
return
|
|
}
|
|
XCTAssertEqual(port, 11086)
|
|
XCTAssertEqual(cause, .refused)
|
|
XCTAssertTrue(systemProxy.applied.isEmpty, "端点不健康时不得写系统代理")
|
|
XCTAssertEqual(controller.systemProxyState, .idle, "系统代理意图默认关闭")
|
|
}
|
|
|
|
func testDisableAgentUnregistersAndCleansRuntimeFiles() async throws {
|
|
let seeded = try makeSeededCatalog()
|
|
let controller = makeController(probe: ProxyRuntimeFixture.FakeProbe.reachable())
|
|
try await controller.activate(seeded.server)
|
|
await controller.setAgentEnabled(true)
|
|
|
|
await controller.setAgentEnabled(false)
|
|
|
|
XCTAssertEqual(controller.state, .off)
|
|
XCTAssertEqual(agent.unregisterCount, 1)
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: runtime.contract.path), "显式停止后清理契约")
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: runtime.pidFile.path))
|
|
}
|
|
|
|
}
|