mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 21:53:43 +00:00
refactor(ng2): unify committed catalog source
This commit is contained in:
@@ -8,6 +8,45 @@ struct CommittedCatalogSnapshot: Equatable, Sendable {
|
||||
let subscriptions: [SubscriptionRecord]
|
||||
}
|
||||
|
||||
/// Runtime commands read the latest committed catalog through this narrow seam.
|
||||
/// The catalog remains a value snapshot; a command captures it once and uses it
|
||||
/// for its full re-expansion operation.
|
||||
@MainActor
|
||||
protocol RuntimeCatalogSnapshotReading: AnyObject {
|
||||
var catalogSnapshot: ConfigurationCatalog { get }
|
||||
}
|
||||
|
||||
/// Shared in-process catalog value. Only the commit coordinator can publish
|
||||
/// after this file's bootstrap; runtime code receives the read-only protocol.
|
||||
@MainActor
|
||||
private final class CommittedCatalogState: RuntimeCatalogSnapshotReading {
|
||||
private(set) var catalogSnapshot: ConfigurationCatalog
|
||||
|
||||
init(catalog: ConfigurationCatalog) {
|
||||
catalogSnapshot = catalog
|
||||
}
|
||||
|
||||
fileprivate func publish(_ catalog: ConfigurationCatalog) {
|
||||
catalogSnapshot = catalog
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time startup load shared by the controller and coordinator. The reader
|
||||
/// exposes only catalog data; subscription state remains coordinator-owned.
|
||||
@MainActor
|
||||
struct CatalogCommitBootstrap {
|
||||
let catalogSnapshotReader: RuntimeCatalogSnapshotReading
|
||||
fileprivate let committedCatalogState: CommittedCatalogState
|
||||
fileprivate let subscriptions: [SubscriptionRecord]
|
||||
|
||||
fileprivate init(document: CatalogDocument) {
|
||||
let state = CommittedCatalogState(catalog: document.catalog)
|
||||
catalogSnapshotReader = state
|
||||
committedCatalogState = state
|
||||
subscriptions = document.subscriptions
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录提交后一次运行时收敛的结构化结果(issue #40)。协调器不生成本地化
|
||||
/// 文案:失败细节原样携带平台运行时给出的点名事实,由上层 presentation 决定
|
||||
/// 如何呈现。
|
||||
@@ -56,17 +95,37 @@ final class CatalogCommitCoordinator: ObservableObject {
|
||||
|
||||
let fileStore: CatalogFileStore
|
||||
private let runtime: CatalogRuntimeSyncing
|
||||
private let catalogState: CommittedCatalogState
|
||||
/// 提交代次:每次提交递增,用于丢弃被更新提交取代的旧同步结果。
|
||||
private var generation = 0
|
||||
|
||||
/// 已提交状态(协调器持有的内存快照;`commit` 成功后整体替换)。
|
||||
private(set) var committedCatalog: ConfigurationCatalog
|
||||
/// 目录在共享来源中只有一份;订阅状态仍由协调器持有。
|
||||
var committedCatalog: ConfigurationCatalog { catalogState.catalogSnapshot }
|
||||
private(set) var committedSubscriptions: [SubscriptionRecord]
|
||||
|
||||
init(fileStore: CatalogFileStore, runtime: CatalogRuntimeSyncing) {
|
||||
init(
|
||||
fileStore: CatalogFileStore,
|
||||
runtime: CatalogRuntimeSyncing,
|
||||
bootstrap: CatalogCommitBootstrap
|
||||
) {
|
||||
self.fileStore = fileStore
|
||||
self.runtime = runtime
|
||||
(committedCatalog, committedSubscriptions) = Self.loadCommitted(from: fileStore)
|
||||
catalogState = bootstrap.committedCatalogState
|
||||
committedSubscriptions = bootstrap.subscriptions
|
||||
}
|
||||
|
||||
/// Test and isolated-module convenience: still performs exactly one document
|
||||
/// load, then gives the coordinator sole publication authority.
|
||||
convenience init(fileStore: CatalogFileStore, runtime: CatalogRuntimeSyncing) {
|
||||
self.init(
|
||||
fileStore: fileStore, runtime: runtime, bootstrap: Self.bootstrap(fileStore: fileStore))
|
||||
}
|
||||
|
||||
/// Loads the initial document once so the composition root can share its
|
||||
/// catalog with the runtime controller before constructing the adapter.
|
||||
/// Direct out-of-band file edits are not observed during a GUI session.
|
||||
static func bootstrap(fileStore: CatalogFileStore) -> CatalogCommitBootstrap {
|
||||
CatalogCommitBootstrap(document: loadCommitted(from: fileStore))
|
||||
}
|
||||
|
||||
/// 普通目录变更的唯一提交管线。任一同步步骤失败则已提交状态不动并原样
|
||||
@@ -79,7 +138,7 @@ final class CatalogCommitCoordinator: ObservableObject {
|
||||
let result = try mutate(&workingCatalog, &workingSubscriptions)
|
||||
try fileStore.save(
|
||||
CatalogDocument(catalog: workingCatalog, subscriptions: workingSubscriptions))
|
||||
committedCatalog = workingCatalog
|
||||
catalogState.publish(workingCatalog)
|
||||
committedSubscriptions = workingSubscriptions
|
||||
scheduleRuntimeSync(
|
||||
CommittedCatalogSnapshot(catalog: workingCatalog, subscriptions: workingSubscriptions))
|
||||
@@ -89,7 +148,9 @@ final class CatalogCommitCoordinator: ObservableObject {
|
||||
/// Legacy 导入等独立路径直接落盘后对齐已提交状态;不触发运行时收敛
|
||||
/// (导入的运行时边界由独立路径自行驱动,不经普通提交管线)。
|
||||
func reloadCommittedStateFromStore() {
|
||||
(committedCatalog, committedSubscriptions) = Self.loadCommitted(from: fileStore)
|
||||
let document = Self.loadCommitted(from: fileStore)
|
||||
catalogState.publish(document.catalog)
|
||||
committedSubscriptions = document.subscriptions
|
||||
}
|
||||
|
||||
/// 异步收敛调度:无活动目标不调用运行时;同步开始前与结束后都以代次
|
||||
@@ -114,9 +175,8 @@ final class CatalogCommitCoordinator: ObservableObject {
|
||||
/// 用户经由导入/设置路径修复。
|
||||
private static func loadCommitted(
|
||||
from fileStore: CatalogFileStore
|
||||
) -> (catalog: ConfigurationCatalog, subscriptions: [SubscriptionRecord]) {
|
||||
let loaded = (try? fileStore.load()) ?? CatalogDocument()
|
||||
return (loaded.catalog, loaded.subscriptions)
|
||||
) -> CatalogDocument {
|
||||
(try? fileStore.load()) ?? CatalogDocument()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,13 @@ struct ShadowsocksXNG2App: App {
|
||||
|
||||
init() {
|
||||
let dependencies = ApplicationDependencies.make()
|
||||
// The catalog document is read exactly once at startup. The coordinator
|
||||
// publishes later committed snapshots; the controller receives read-only
|
||||
// access to this same in-process source.
|
||||
let catalogBootstrap = CatalogCommitCoordinator.bootstrap(
|
||||
fileStore: dependencies.catalogFileStore)
|
||||
let controller = ProxyRuntimeController(
|
||||
catalogFileStore: dependencies.catalogFileStore,
|
||||
catalogSnapshotReader: catalogBootstrap.catalogSnapshotReader,
|
||||
activationFileStore: dependencies.activationFileStore,
|
||||
runtimeFileStore: dependencies.runtimeFileStore,
|
||||
credentials: dependencies.credentials,
|
||||
@@ -27,7 +32,8 @@ struct ShadowsocksXNG2App: App {
|
||||
// 各 scene 不再重复设置提交回调;Legacy 导入后的 2.0 运行时边界同为一次性注入。
|
||||
let coordinator = CatalogCommitCoordinator(
|
||||
fileStore: dependencies.catalogFileStore,
|
||||
runtime: ProxyRuntimeSyncAdapter(controller: controller))
|
||||
runtime: ProxyRuntimeSyncAdapter(controller: controller),
|
||||
bootstrap: catalogBootstrap)
|
||||
let catalogWorkflow = CatalogWorkflow(
|
||||
coordinator: coordinator,
|
||||
credentials: dependencies.credentials,
|
||||
|
||||
@@ -47,8 +47,7 @@ final class ProxyRuntimeController: ObservableObject {
|
||||
@Published private(set) var skippedServers: [SkippedServer] = []
|
||||
private(set) var machine: ActivationStateMachine
|
||||
|
||||
private var catalog: ConfigurationCatalog
|
||||
private let catalogFileStore: CatalogFileStore
|
||||
private let catalogSnapshotReader: RuntimeCatalogSnapshotReading
|
||||
private let activationFileStore: ActivationStateFileStore
|
||||
private let runtimeFileStore: RuntimeFileStore
|
||||
private let credentials: CredentialStoring
|
||||
@@ -76,8 +75,7 @@ final class ProxyRuntimeController: ObservableObject {
|
||||
@Published private(set) var proxyMode: ProxyMode
|
||||
|
||||
init(
|
||||
catalogFileStore: CatalogFileStore = CatalogFileStore(
|
||||
fileURL: CatalogFileStore.defaultFileURL()),
|
||||
catalogSnapshotReader: RuntimeCatalogSnapshotReading,
|
||||
activationFileStore: ActivationStateFileStore = ActivationStateFileStore(
|
||||
fileURL: ActivationStateFileStore.defaultFileURL()),
|
||||
runtimeFileStore: RuntimeFileStore = RuntimeFileStore(),
|
||||
@@ -96,7 +94,7 @@ final class ProxyRuntimeController: ObservableObject {
|
||||
firewallPollIntervalNanoseconds: UInt64 = 2_000_000_000,
|
||||
sendSignal: @escaping @Sendable (Int32, Int32) -> Int32 = { kill($0, $1) }
|
||||
) {
|
||||
self.catalogFileStore = catalogFileStore
|
||||
self.catalogSnapshotReader = catalogSnapshotReader
|
||||
self.activationFileStore = activationFileStore
|
||||
self.runtimeFileStore = runtimeFileStore
|
||||
self.credentials = credentials
|
||||
@@ -121,7 +119,6 @@ final class ProxyRuntimeController: ObservableObject {
|
||||
self.firewallExecutableURLs = firewallExecutableURLs ?? Self.defaultFirewallExecutableURLs
|
||||
self.firewallPollIntervalNanoseconds = firewallPollIntervalNanoseconds
|
||||
self.sendSignal = sendSignal
|
||||
catalog = (try? catalogFileStore.load().catalog) ?? ConfigurationCatalog()
|
||||
let persistedTarget = try? activationFileStore.loadActiveTargetID()
|
||||
machine = ActivationStateMachine(activeTargetID: persistedTarget)
|
||||
activeTargetID = machine.activeTargetID
|
||||
@@ -161,7 +158,7 @@ final class ProxyRuntimeController: ObservableObject {
|
||||
/// `.rejectedActivation`;意外错误 throws 并进入 `serviceFailed`。
|
||||
@discardableResult
|
||||
func activate(_ target: NodeID) async throws -> ActivationCommandOutcome {
|
||||
reloadCatalog()
|
||||
let catalog = catalogSnapshotReader.catalogSnapshot
|
||||
do {
|
||||
let configuration = try machine.activate(
|
||||
target, in: catalog, credentials: credentials, plugins: plugins, listen: settings.listen,
|
||||
@@ -255,8 +252,8 @@ final class ProxyRuntimeController: ObservableObject {
|
||||
/// 事实来源——注册过即视为开启并重校验;随后与磁盘契约对齐(相同内容跳
|
||||
/// 过写入)。GUI 崩溃期间 agent 与 wrapper 均不受影响。
|
||||
func resyncOnLaunch() async {
|
||||
reloadCatalog()
|
||||
switch reexpand() {
|
||||
let catalog = catalogSnapshotReader.catalogSnapshot
|
||||
switch reexpand(in: catalog) {
|
||||
case .deployed(let configuration):
|
||||
let status = agent.status
|
||||
if status == .registered || status == .requiresApproval {
|
||||
@@ -293,8 +290,8 @@ final class ProxyRuntimeController: ObservableObject {
|
||||
presentNoActiveTarget()
|
||||
return
|
||||
}
|
||||
reloadCatalog()
|
||||
switch reexpand() {
|
||||
let catalog = catalogSnapshotReader.catalogSnapshot
|
||||
switch reexpand(in: catalog) {
|
||||
case .deployed(let configuration):
|
||||
await deploy(configuration.document)
|
||||
case .clearedAndStopped(let failure):
|
||||
@@ -454,8 +451,7 @@ extension ProxyRuntimeController {
|
||||
/// → 原子更新运行时;目标失效 → 清除目标并停止代理。返回结构化收敛结果,
|
||||
/// 健康检查耗时属于本调用的异步收敛阶段,不改变「目录已提交」的事实。
|
||||
func catalogDidCommit(snapshot catalog: ConfigurationCatalog) async -> RuntimeSyncOutcome {
|
||||
self.catalog = catalog
|
||||
switch reexpand() {
|
||||
switch reexpand(in: catalog) {
|
||||
case .deployed(let configuration):
|
||||
guard state != .off else {
|
||||
return .revalidated
|
||||
@@ -497,6 +493,7 @@ extension ProxyRuntimeController {
|
||||
/// before persistence, so the mode kind and its URL commit as one logical
|
||||
/// change and a validation failure precedes the write.
|
||||
func updateSettings(_ proposed: ProxySettings) async throws {
|
||||
let catalog = catalogSnapshotReader.catalogSnapshot
|
||||
var next = proposed
|
||||
var resolvedMode = proxyMode
|
||||
if case .externalPAC = proxyMode {
|
||||
@@ -515,7 +512,7 @@ extension ProxyRuntimeController {
|
||||
settingsUnreadable = false
|
||||
|
||||
guard state != .off else { return }
|
||||
switch reexpand() {
|
||||
switch reexpand(in: catalog) {
|
||||
case .deployed(let configuration):
|
||||
await deploy(configuration.document)
|
||||
case .clearedAndStopped(let failure):
|
||||
@@ -557,7 +554,6 @@ extension ProxyRuntimeController {
|
||||
listenSettingsUnreadable = false
|
||||
proxyMode = Self.makeProxyMode(from: restored)
|
||||
}
|
||||
reloadCatalog()
|
||||
let persistedTarget = try? activationFileStore.loadActiveTargetID()
|
||||
machine = ActivationStateMachine(activeTargetID: persistedTarget ?? nil)
|
||||
activeTargetID = machine.activeTargetID
|
||||
@@ -743,12 +739,9 @@ extension ProxyRuntimeController {
|
||||
|
||||
// MARK: - 目录同步
|
||||
|
||||
private func reloadCatalog() {
|
||||
catalog = (try? catalogFileStore.load().catalog) ?? catalog
|
||||
}
|
||||
|
||||
/// 目录以磁盘为事实来源重载后重展开;目标被清除时一并发布。
|
||||
private func reexpand() -> ActivationEffect? {
|
||||
/// Re-expands one command-start snapshot; a concurrently published catalog
|
||||
/// is intentionally observed by the next command, not halfway through this one.
|
||||
private func reexpand(in catalog: ConfigurationCatalog) -> ActivationEffect? {
|
||||
let effect = machine.catalogDidCommit(
|
||||
catalog, credentials: credentials, plugins: plugins, listen: settings.listen,
|
||||
timeout: settings.timeoutSeconds, verbose: settings.verboseLogging,
|
||||
|
||||
@@ -69,6 +69,7 @@ final class CatalogCommitCoordinatorTests: XCTestCase {
|
||||
private var fileURL: URL!
|
||||
private var runtime: FakeCatalogRuntime!
|
||||
private var coordinator: CatalogCommitCoordinator!
|
||||
private var bootstrap: CatalogCommitBootstrap!
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
@@ -87,7 +88,9 @@ final class CatalogCommitCoordinatorTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func makeCoordinator() -> CatalogCommitCoordinator {
|
||||
CatalogCommitCoordinator(fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
|
||||
let fileStore = CatalogFileStore(fileURL: fileURL)
|
||||
bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore)
|
||||
return CatalogCommitCoordinator(fileStore: fileStore, runtime: runtime, bootstrap: bootstrap)
|
||||
}
|
||||
|
||||
/// 预置目录后重建协调器(模拟已落盘的既有状态)。
|
||||
@@ -112,6 +115,9 @@ final class CatalogCommitCoordinatorTests: XCTestCase {
|
||||
}
|
||||
|
||||
XCTAssertTrue(coordinator.committedCatalog.contains(added), "提交后已发布新状态")
|
||||
XCTAssertTrue(
|
||||
bootstrap.catalogSnapshotReader.catalogSnapshot.contains(added),
|
||||
"controller reader 与 coordinator 观察同一已提交目录")
|
||||
XCTAssertEqual(try persistedCatalog().rootChildren, [added], "变更已原子持久化")
|
||||
XCTAssertEqual(runtime.convergeCount, 0, "无活动目标不驱动运行时")
|
||||
XCTAssertEqual(coordinator.syncStatus, .idle)
|
||||
@@ -281,7 +287,29 @@ final class CatalogCommitCoordinatorTests: XCTestCase {
|
||||
coordinator.reloadCommittedStateFromStore()
|
||||
|
||||
XCTAssertEqual(coordinator.committedCatalog.rootChildren, [groupID], "已提交状态对齐磁盘")
|
||||
XCTAssertEqual(
|
||||
bootstrap.catalogSnapshotReader.catalogSnapshot.rootChildren, [groupID],
|
||||
"独立导入 reload 发布到共享目录来源")
|
||||
XCTAssertEqual(coordinator.committedSubscriptions.count, 1)
|
||||
XCTAssertEqual(runtime.convergeCount, 0, "对齐不触发运行时收敛")
|
||||
}
|
||||
|
||||
func testFailedPersistenceDoesNotPublishSharedCatalog() throws {
|
||||
let blocker = workDir.appendingPathComponent("not-a-directory")
|
||||
try Data().write(to: blocker)
|
||||
let blockedStore = CatalogFileStore(fileURL: blocker.appendingPathComponent("catalog.json"))
|
||||
let blockedBootstrap = CatalogCommitCoordinator.bootstrap(fileStore: blockedStore)
|
||||
let blockedCoordinator = CatalogCommitCoordinator(
|
||||
fileStore: blockedStore, runtime: runtime, bootstrap: blockedBootstrap)
|
||||
let original = blockedBootstrap.catalogSnapshotReader.catalogSnapshot
|
||||
|
||||
XCTAssertThrowsError(
|
||||
try blockedCoordinator.commit { catalog, _ in
|
||||
try catalog.addTestServer("不能发布")
|
||||
})
|
||||
|
||||
XCTAssertEqual(blockedBootstrap.catalogSnapshotReader.catalogSnapshot, original)
|
||||
XCTAssertEqual(blockedCoordinator.committedCatalog, original)
|
||||
XCTAssertEqual(runtime.convergeCount, 0, "持久化失败不驱动运行时")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ final class CatalogWorkflowRollbackTests: XCTestCase {
|
||||
private var activationURL: URL!
|
||||
private var credentials: InMemoryCredentialStore!
|
||||
private var runtime: FakeCatalogRuntime!
|
||||
private var bootstrap: CatalogCommitBootstrap!
|
||||
private var rootsSeenByLegacyPostImport: [NodeID]?
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
@@ -46,8 +48,10 @@ final class CatalogWorkflowRollbackTests: XCTestCase {
|
||||
postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil
|
||||
) -> CatalogWorkflow {
|
||||
runtime.hasActiveTarget = true
|
||||
let fileStore = CatalogFileStore(fileURL: fileURL)
|
||||
bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore)
|
||||
let coordinator = CatalogCommitCoordinator(
|
||||
fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
|
||||
fileStore: fileStore, runtime: runtime, bootstrap: bootstrap)
|
||||
return CatalogWorkflow(
|
||||
coordinator: coordinator,
|
||||
credentials: credentials,
|
||||
@@ -221,7 +225,13 @@ final class CatalogWorkflowRollbackTests: XCTestCase {
|
||||
|
||||
let service = try makeLegacyService()
|
||||
var postCalls = 0
|
||||
let workflow = makeWorkflow(legacyImportService: service) { _ in postCalls += 1 }
|
||||
rootsSeenByLegacyPostImport = nil
|
||||
let workflow = makeWorkflow(legacyImportService: service) { [weak self] _ in
|
||||
postCalls += 1
|
||||
self?.rootsSeenByLegacyPostImport =
|
||||
self?.bootstrap.catalogSnapshotReader
|
||||
.catalogSnapshot.rootChildren
|
||||
}
|
||||
XCTAssertTrue(workflow.legacyImportState.shouldOffer, "首启发现快照后应提供自动导入入口")
|
||||
// 有活动目标也不得触发运行时收敛(导入不启动代理、不写系统代理)。
|
||||
XCTAssertTrue(runtime.hasActiveTarget)
|
||||
@@ -234,6 +244,9 @@ final class CatalogWorkflowRollbackTests: XCTestCase {
|
||||
XCTAssertTrue(group.isGroup)
|
||||
XCTAssertTrue(group.isManual)
|
||||
XCTAssertEqual(group.childCount, 1)
|
||||
XCTAssertEqual(
|
||||
rootsSeenByLegacyPostImport, [group.id],
|
||||
"独立导入先 reload 并发布共享目录快照,再进入运行时清理回调")
|
||||
XCTAssertEqual(runtime.convergeCount, 0, "导入不经提交管线触发运行时收敛")
|
||||
XCTAssertEqual(postCalls, 1, "导入后的运行时边界只经注入闭包(独立于普通提交)")
|
||||
XCTAssertEqual(workflow.legacyImportState.completed, true, "成功导入写完成标记")
|
||||
|
||||
@@ -105,4 +105,110 @@ extension ProxyRuntimeControllerTests {
|
||||
XCTAssertTrue(systemProxy.applied.isEmpty)
|
||||
XCTAssertEqual(agent.registerCount, 0)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Coordinator/controller integration for the shared committed catalog source.
|
||||
@MainActor
|
||||
final class CatalogRuntimeSnapshotIntegrationTests: XCTestCase {
|
||||
private var runtime: ProxyRuntimeFixture.TemporaryRuntime!
|
||||
private var catalogFileURL: URL!
|
||||
private var activationFileURL: URL!
|
||||
private var credentials: InMemoryCredentialStore!
|
||||
private var agent: ProxyRuntimeFixture.FakeLaunchAgent!
|
||||
private var systemProxy: ProxyRuntimeFixture.FakeSystemProxy!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
|
||||
catalogFileURL = runtime.directory.appendingPathComponent("catalog.json")
|
||||
activationFileURL = runtime.directory.appendingPathComponent("activation.json")
|
||||
credentials = InMemoryCredentialStore()
|
||||
agent = ProxyRuntimeFixture.FakeLaunchAgent()
|
||||
systemProxy = ProxyRuntimeFixture.FakeSystemProxy()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try? FileManager.default.removeItem(at: runtime.directory)
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
func testCommitWithoutActiveTargetFeedsLaterActivationThroughSharedSnapshot() async throws {
|
||||
let fileStore = CatalogFileStore(fileURL: catalogFileURL)
|
||||
let bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore)
|
||||
let controller = makeController(catalogSnapshotReader: bootstrap.catalogSnapshotReader)
|
||||
let coordinator = CatalogCommitCoordinator(
|
||||
fileStore: fileStore,
|
||||
runtime: ProxyRuntimeSyncAdapter(controller: controller),
|
||||
bootstrap: bootstrap)
|
||||
|
||||
let server = try coordinator.commit { catalog, _ in
|
||||
try ActivationFixture.addPlainServer(
|
||||
"提交后激活", in: &catalog, credentials: credentials)
|
||||
}
|
||||
|
||||
XCTAssertEqual(coordinator.syncStatus, .idle, "无目标时目录只发布,不触发运行时")
|
||||
XCTAssertNil(controller.activeTargetID)
|
||||
try await controller.activate(server)
|
||||
|
||||
XCTAssertEqual(controller.activeTargetID, server, "激活读取协调器刚发布的唯一快照")
|
||||
XCTAssertEqual(controller.state, .off, "该命令仅选择目标,不启动代理")
|
||||
}
|
||||
|
||||
func testCoordinatorUsesProductionAdapterToClearRemovedActiveTarget() async throws {
|
||||
let server = try makeSeededCatalog()
|
||||
try ActivationStateFileStore(fileURL: activationFileURL).save(activeTargetID: server)
|
||||
let fileStore = CatalogFileStore(fileURL: catalogFileURL)
|
||||
let bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore)
|
||||
let controller = makeController(catalogSnapshotReader: bootstrap.catalogSnapshotReader)
|
||||
let coordinator = CatalogCommitCoordinator(
|
||||
fileStore: fileStore,
|
||||
runtime: ProxyRuntimeSyncAdapter(controller: controller),
|
||||
bootstrap: bootstrap)
|
||||
|
||||
try coordinator.commit { catalog, _ in try catalog.remove(server) }
|
||||
|
||||
await waitUntilRuntimeSettles(
|
||||
{
|
||||
if case .finished = coordinator.syncStatus { return true }
|
||||
return false
|
||||
}())
|
||||
|
||||
guard case .finished(_, .clearedAndStopped) = coordinator.syncStatus else {
|
||||
return XCTFail("生产 adapter 应以刚提交快照清理无效目标")
|
||||
}
|
||||
XCTAssertNil(controller.activeTargetID)
|
||||
XCTAssertNil(
|
||||
try ActivationStateFileStore(fileURL: activationFileURL).loadActiveTargetID(),
|
||||
"adapter 收敛后持久化清除失效目标")
|
||||
}
|
||||
|
||||
private func makeController(
|
||||
catalogSnapshotReader: RuntimeCatalogSnapshotReading
|
||||
) -> ProxyRuntimeController {
|
||||
ProxyRuntimeController(
|
||||
catalogSnapshotReader: catalogSnapshotReader,
|
||||
activationFileStore: ActivationStateFileStore(fileURL: activationFileURL),
|
||||
runtimeFileStore: RuntimeFileStore(fileURL: runtime.contract),
|
||||
credentials: credentials,
|
||||
plugins: ActivationFixture.plugins,
|
||||
listenRestore: RestoredListenSettings(
|
||||
settings: ActivationFixture.listen, unreadableError: nil),
|
||||
settingsStore: InMemoryProxySettingsStore(),
|
||||
agent: agent,
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
|
||||
pacProbe: ProxyRuntimeFixture.FakePACProbe(),
|
||||
systemProxy: systemProxy,
|
||||
firewallExecutableURLs: [URL(fileURLWithPath: "/bundle/Helpers/sslocal")],
|
||||
firewallPollIntervalNanoseconds: 1_000_000,
|
||||
sendSignal: { _, _ in 0 })
|
||||
}
|
||||
|
||||
private func makeSeededCatalog() throws -> NodeID {
|
||||
var catalog = ConfigurationCatalog()
|
||||
let server = try ActivationFixture.addPlainServer(
|
||||
"待删除", in: &catalog, credentials: credentials)
|
||||
try CatalogFileStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog))
|
||||
return server
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
) -> ProxyRuntimeController {
|
||||
agent.setStatus(agentStatus)
|
||||
return ProxyRuntimeController(
|
||||
catalogFileStore: CatalogFileStore(fileURL: catalogFileURL),
|
||||
catalogSnapshotReader: ProxyRuntimeFixture.catalogSnapshotReader(at: catalogFileURL),
|
||||
activationFileStore: ActivationStateFileStore(fileURL: activationFileURL),
|
||||
runtimeFileStore: RuntimeFileStore(fileURL: runtime.contract),
|
||||
credentials: credentials,
|
||||
|
||||
@@ -25,6 +25,12 @@ enum ProxyRuntimeFixture {
|
||||
pidFile: directory.appendingPathComponent("agent.pid"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func catalogSnapshotReader(at fileURL: URL) -> RuntimeCatalogSnapshotReading {
|
||||
CatalogCommitCoordinator.bootstrap(fileStore: CatalogFileStore(fileURL: fileURL))
|
||||
.catalogSnapshotReader
|
||||
}
|
||||
|
||||
static func unusedLoopbackPort() throws -> Int {
|
||||
let descriptor = socket(AF_INET, SOCK_STREAM, 0)
|
||||
guard descriptor >= 0 else { throw POSIXError(.ENOTSOCK) }
|
||||
|
||||
@@ -116,8 +116,11 @@ final class SettingsWorkflowTests: XCTestCase {
|
||||
// MARK: - 夹具
|
||||
|
||||
private func makePair() -> (controller: ProxyRuntimeController, workflow: SettingsWorkflow) {
|
||||
let catalogSnapshotReader = CatalogCommitCoordinator.bootstrap(
|
||||
fileStore: CatalogFileStore(fileURL: catalogFileURL)
|
||||
).catalogSnapshotReader
|
||||
let controller = ProxyRuntimeController(
|
||||
catalogFileStore: CatalogFileStore(fileURL: catalogFileURL),
|
||||
catalogSnapshotReader: catalogSnapshotReader,
|
||||
activationFileStore: ActivationStateFileStore(fileURL: activationFileURL),
|
||||
runtimeFileStore: RuntimeFileStore(fileURL: runtime.contract),
|
||||
credentials: credentials,
|
||||
|
||||
@@ -33,8 +33,11 @@ final class UnreadableListenSettingsTests: XCTestCase {
|
||||
listenUnreadable: ListenSettingsStoreError
|
||||
) -> ProxyRuntimeController {
|
||||
agent.setStatus(agentStatus)
|
||||
let catalogSnapshotReader = CatalogCommitCoordinator.bootstrap(
|
||||
fileStore: CatalogFileStore(fileURL: catalogFileURL)
|
||||
).catalogSnapshotReader
|
||||
return ProxyRuntimeController(
|
||||
catalogFileStore: CatalogFileStore(fileURL: catalogFileURL),
|
||||
catalogSnapshotReader: catalogSnapshotReader,
|
||||
activationFileStore: ActivationStateFileStore(fileURL: activationFileURL),
|
||||
runtimeFileStore: RuntimeFileStore(fileURL: runtime.contract),
|
||||
credentials: credentials,
|
||||
|
||||
Reference in New Issue
Block a user