feat(ng2): decouple catalog commit from proxy runtime sync (#40)

Add an application-layer CatalogCommitCoordinator as the single commit
entry for normal configuration changes: copy -> mutate -> atomic persist
-> publish committed state -> async runtime convergence toward the just
committed in-memory snapshot. Commits complete at persist; convergence
reports structured outcomes (converged / revalidated / failed /
clearedAndStopped) and never blocks or rolls back the commit. Each commit
bumps a generation; superseded syncs end before starting or have their
late results discarded, so only the latest snapshot can win. No automatic
retry.

CatalogRuntimeSyncing is the one new replacement seam: the production
ProxyRuntimeSyncAdapter drives the existing ProxyRuntimeController via a
snapshot-based catalogDidCommit; tests use a deterministic fake. When no
active target exists a commit no longer touches the runtime at all.

CatalogViewModel stays as a compatibility facade with unchanged published
state; the optional postCommit callback and the per-scene callback wiring
in MainApp are removed - the composition root wires the coordinator,
adapter and the (independent) Legacy import boundary exactly once.
This commit is contained in:
Qiu Yuzhou
2026-09-22 09:48:38 +08:00
parent ea801d5f38
commit 374f0da4d7
10 changed files with 571 additions and 123 deletions
@@ -0,0 +1,139 @@
import Combine
import Foundation
/// 已提交目录的内存快照(issue #40):协调器与运行时缝之间的传值。运行时
/// 收敛以此为唯一目标,不回读磁盘;磁盘仍是重启与跨进程恢复的权威来源。
struct CommittedCatalogSnapshot: Equatable, Sendable {
let catalog: ConfigurationCatalog
let subscriptions: [SubscriptionRecord]
}
/// 目录提交后一次运行时收敛的结构化结果(issue #40)。协调器不生成本地化
/// 文案:失败细节原样携带平台运行时给出的点名事实,由上层 presentation 决定
/// 如何呈现。
enum RuntimeSyncOutcome: Equatable, Sendable {
/// 代理未开启:活动目标已按提交快照重校验,未部署。
case revalidated
/// 运行时已向提交快照收敛(部署已执行且启动健康通过)。
case converged(skippedServers: [SkippedServer])
/// 部署已执行但运行时未收敛;细节为平台运行时的点名事实(nil 表示等待
/// 用户在系统设置批准等无额外细节的终态)。
case failed(detail: String?)
/// 活动目标失效:目标已清除且代理已停止(无静默回退)。
case clearedAndStopped(ActivationFailure)
}
/// 运行时收敛的可观察阶段(issue #40):目录已提交 → 运行时同步中 → 结束。
/// generation 为提交代次;被更新提交取代的旧代次不写最终状态。
enum RuntimeSyncStatus: Equatable {
/// 无进行中或已完成的同步(无活动目标时提交保持此状态)。
case idle
/// 正在向该代次提交快照收敛。
case syncing(generation: Int)
/// 该代次收敛已结束。
case finished(generation: Int, outcome: RuntimeSyncOutcome)
}
/// 运行时同步缝(issue #40):目录提交协调器与代理运行时之间的窄接口。
/// 生产实现适配现有代理运行时控制器;测试注入确定性 fake。
@MainActor
protocol CatalogRuntimeSyncing: AnyObject {
/// 当前是否存在活动目标(无目标时目录提交不驱动运行时)。
var hasActiveTarget: Bool { get }
/// 让运行时向刚提交的目录快照收敛。健康检查、LaunchAgent 与端点启动的
/// 耗时属于本调用的异步收敛阶段。
func converge(to snapshot: CommittedCatalogSnapshot) async -> RuntimeSyncOutcome
}
/// 目录提交—运行时同步协调器(issue #40):普通配置变更的唯一提交入口。
/// 管线为「副本变更 → 校验并原子持久化 → 发布已提交状态 → 异步运行时收敛」;
/// 目录提交在持久化成功后立即完成,不因运行时失败回滚目录,也不被健康检查
/// 或启动耗时阻塞。每次提交递增代次:旧代次自然结束,但不得覆盖最新代次的
/// 最终状态;没有自动重试,后续提交、显式激活或启动代理会发起新的同步尝试。
@MainActor
final class CatalogCommitCoordinator: ObservableObject {
/// 最新提交的运行时收敛阶段(结构化;呈现由上层决定)。
@Published private(set) var syncStatus: RuntimeSyncStatus = .idle
let fileStore: CatalogFileStore
private let runtime: CatalogRuntimeSyncing
/// 提交代次:每次提交递增,用于丢弃被更新提交取代的旧同步结果。
private var generation = 0
/// 已提交状态(协调器持有的内存快照;`commit` 成功后整体替换)。
private(set) var committedCatalog: ConfigurationCatalog
private(set) var committedSubscriptions: [SubscriptionRecord]
init(fileStore: CatalogFileStore, runtime: CatalogRuntimeSyncing) {
self.fileStore = fileStore
self.runtime = runtime
(committedCatalog, committedSubscriptions) = Self.loadCommitted(from: fileStore)
}
/// 普通目录变更的唯一提交管线。任一同步步骤失败则已提交状态不动并原样
/// 上抛(凭据等外部副作用由调用方回滚);运行时收敛在持久化成功后异步开始。
func commit<T>(
_ mutate: (inout ConfigurationCatalog, inout [SubscriptionRecord]) throws -> T
) throws -> T {
var workingCatalog = committedCatalog
var workingSubscriptions = committedSubscriptions
let result = try mutate(&workingCatalog, &workingSubscriptions)
try fileStore.save(
CatalogDocument(catalog: workingCatalog, subscriptions: workingSubscriptions))
committedCatalog = workingCatalog
committedSubscriptions = workingSubscriptions
scheduleRuntimeSync(
CommittedCatalogSnapshot(catalog: workingCatalog, subscriptions: workingSubscriptions))
return result
}
/// Legacy 导入等独立路径直接落盘后对齐已提交状态;不触发运行时收敛
/// (导入的运行时边界由独立路径自行驱动,不经普通提交管线)。
func reloadCommittedStateFromStore() {
(committedCatalog, committedSubscriptions) = Self.loadCommitted(from: fileStore)
}
/// 异步收敛调度:无活动目标不调用运行时;同步开始前与结束后都以代次
/// 校验——被更新提交取代的旧同步直接结束或丢弃结果,不覆盖最新状态。
private func scheduleRuntimeSync(_ snapshot: CommittedCatalogSnapshot) {
generation += 1
let generation = generation
guard runtime.hasActiveTarget else {
syncStatus = .idle
return
}
syncStatus = .syncing(generation: generation)
Task { @MainActor [weak self] in
guard let self, self.generation == generation else { return }
let outcome = await self.runtime.converge(to: snapshot)
guard !Task.isCancelled, self.generation == generation else { return }
self.syncStatus = .finished(generation: generation, outcome: outcome)
}
}
/// 磁盘读取的容错口径与首次加载一致:损坏文档回退全新空文档,等待
/// 用户经由导入/设置路径修复。
private static func loadCommitted(
from fileStore: CatalogFileStore
) -> (catalog: ConfigurationCatalog, subscriptions: [SubscriptionRecord]) {
let loaded = (try? fileStore.load()) ?? CatalogDocument()
return (loaded.catalog, loaded.subscriptions)
}
}
/// 生产运行时适配器(issue #40):协调器与现有代理运行时控制器之间的唯一
/// 生产接线,由应用组合根建立。
@MainActor
final class ProxyRuntimeSyncAdapter: CatalogRuntimeSyncing {
private let controller: ProxyRuntimeController
init(controller: ProxyRuntimeController) {
self.controller = controller
}
var hasActiveTarget: Bool { controller.isActiveTargetPresent }
func converge(to snapshot: CommittedCatalogSnapshot) async -> RuntimeSyncOutcome {
await controller.catalogDidCommit(snapshot: snapshot.catalog)
}
}
@@ -3,8 +3,8 @@ import Foundation
/// 订阅生命周期(spec #21 D4,issue #35):粘贴 HTTPS URL 创建、单个/全部
/// 立即更新、编辑 URL(保留身份)、删除(递归清除)。刷新 = 完整获取、解析、
/// 校验后同文档原子提交快照与刷新状态;任何失败保留最后成功快照、活动目标与
/// 运行状态,仅标记来源失败。每次提交都触发 postCommit
/// 重展开(订阅子树变更 → 活动目标原子跟随或清除停止)。
/// 运行状态,仅标记来源失败。每次提交都经目录提交协调器异步触发运行时收敛
/// (issue #40;订阅子树变更 → 活动目标原子跟随或清除停止)。
extension CatalogViewModel {
/// 创建订阅:校验 HTTPS URL → 落订阅记录与空固定分组 → 立即首次刷新。
/// 首次刷新失败留空分组加错误态(状态在刷新内标记,创建本身总是成功)。
@@ -17,7 +17,7 @@ extension CatalogViewModel {
urlRef: .fresh(),
status: .never)
try credentials.save(url.absoluteString, for: record.urlRef)
try await commitDocument { catalog, subscriptions in
try commitDocument { catalog, subscriptions in
// 固定分组挂在目录根(CONTEXT.md「Subscription group」);名称以 host
// 兜底,首次成功刷新后跟随远端。
try catalog.addGroup(url.host ?? "", source: .subscription, id: record.groupID)
@@ -73,7 +73,7 @@ extension CatalogViewModel {
throw SubscriptionFormError.notFound
}
var removedEntries: [CatalogEntry] = []
try await commitDocument { catalog, subscriptions in
try commitDocument { catalog, subscriptions in
removedEntries = try catalog.removeSubscriptionSubtree(of: record.groupID)
subscriptions.removeAll { $0.id == id }
}
@@ -148,7 +148,7 @@ extension CatalogViewModel {
var removedServers: [CatalogEntry] = []
var credentialJournal = CredentialWriteJournal(credentials: credentials)
do {
try await commitDocument { catalog, subscriptions in
try commitDocument { catalog, subscriptions in
// 凭据引用按节点身份复用:延续节点覆盖写秘密,不新增孤儿引用;
// journal 保证快照提交失败时恢复旧秘密。
var reusedRefs: [NodeID: ServerCredentialRefs] = [:]
@@ -182,7 +182,7 @@ extension CatalogViewModel {
}
private func markRefreshFailed(subscriptionID: NodeID, reason: String) async {
try? await commitDocument { _, subscriptions in
try? commitDocument { _, subscriptions in
if let index = subscriptions.firstIndex(where: { $0.id == subscriptionID }) {
subscriptions[index].status = .failed(at: Date(), reason: reason)
}
+33 -35
View File
@@ -3,8 +3,9 @@ import SwiftUI
/// 主窗口视图模型(issue #32/#35):把 ConfigurationCatalog 领域语义接到树
/// 操作、详情表单、添加三入口、分享与订阅生命周期。所有变更走「副本变更 →
/// 落盘 → 发布 → postCommit 重展开」,失败即整体不变更;凭据读写只在模型层
/// 出现。
/// 落盘 → 发布」,失败即整体不变更;凭据读写只在模型层出现。提交管线、
/// 运行时收敛与并发代次由注入的 `CatalogCommitCoordinator` 负责(issue #40),
/// 本类在迁移期间保留为兼容 facade,向现有 SwiftUI 调用方提供已发布状态。
@MainActor
final class CatalogViewModel: ObservableObject {
@Published private(set) var catalog: ConfigurationCatalog
@@ -21,29 +22,32 @@ final class CatalogViewModel: ObservableObject {
@Published private(set) var legacyImportReport: LegacyImportReport?
private var discoveredLegacySnapshot: LegacySnapshot?
/// 提交管线与运行时收敛的唯一入口(组合根注入,issue #40)。
let coordinator: CatalogCommitCoordinator
private let fileStore: CatalogFileStore
/// 订阅扩展(CatalogViewModel+Subscriptions)同样经此读写凭据。
let credentials: CredentialStoring
private let plugins: ManagedPluginProviding
/// 订阅获取缝(默认 URLSession 实现;测试注入夹具,issue #35)。
var subscriptionFetcher: SubscriptionFetching
/// 目录提交后的运行时重展开(生产接线 `ProxyRuntimeController.catalogDidCommit`)。
var postCommit: (() async -> Void)?
/// Legacy 导入提交后的运行时边界(生产接线为不写系统代理的停止/重载)。
var postLegacyImport: ((LegacyImportOutcome) async -> Void)?
/// Legacy 导入提交后的运行时边界(组合根一次性接线;独立于普通提交管线)。
private let postLegacyImport: ((LegacyImportOutcome) async -> Void)?
private let legacyImportService: LegacyImportService
init(
fileStore: CatalogFileStore = CatalogFileStore(fileURL: CatalogFileStore.defaultFileURL()),
coordinator: CatalogCommitCoordinator,
credentials: CredentialStoring = KeychainCredentialStore(),
plugins: ManagedPluginProviding = BundleManagedPluginProvider(),
subscriptionFetcher: SubscriptionFetching = HTTPSSubscriptionFetcher(),
legacyImportService: LegacyImportService? = nil
legacyImportService: LegacyImportService? = nil,
postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil
) {
self.fileStore = fileStore
self.coordinator = coordinator
fileStore = coordinator.fileStore
self.credentials = credentials
self.plugins = plugins
self.subscriptionFetcher = subscriptionFetcher
self.postLegacyImport = postLegacyImport
self.legacyImportService =
legacyImportService
?? LegacyImportService(
@@ -52,9 +56,8 @@ final class CatalogViewModel: ObservableObject {
activationStore: ActivationStateFileStore(
fileURL: ActivationStateFileStore.defaultFileURL()),
credentials: credentials)
let loaded = (try? fileStore.load()) ?? CatalogDocument()
catalog = loaded.catalog
subscriptions = loaded.subscriptions
catalog = coordinator.committedCatalog
subscriptions = coordinator.committedSubscriptions
refreshLegacyImportState()
}
@@ -87,9 +90,9 @@ final class CatalogViewModel: ObservableObject {
snapshot = current
}
let outcome = try legacyImportService.importSnapshot(snapshot, reimport: reimport)
let loaded = try fileStore.load()
catalog = loaded.catalog
subscriptions = loaded.subscriptions
coordinator.reloadCommittedStateFromStore()
catalog = coordinator.committedCatalog
subscriptions = coordinator.committedSubscriptions
legacyImportReport = outcome.report
refreshLegacyImportState()
await postLegacyImport?(outcome)
@@ -140,7 +143,7 @@ final class CatalogViewModel: ObservableObject {
}
guard !prepared.isEmpty else { return (0, failures) }
do {
try await commit { catalog in
try commit { catalog in
for item in prepared {
try catalog.addServer(item.fields, to: parent)
}
@@ -158,7 +161,7 @@ final class CatalogViewModel: ObservableObject {
func addGroup(named name: String, into parent: NodeID?) async throws -> NodeID {
let trimmed = name.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { throw ServerFormError.emptyName }
return try await commit { catalog in
return try commit { catalog in
try catalog.addGroup(trimmed, to: parent)
}
}
@@ -166,21 +169,21 @@ final class CatalogViewModel: ObservableObject {
func renameGroup(_ id: NodeID, to name: String) async throws {
let trimmed = name.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { throw ServerFormError.emptyName }
try await commit { catalog in
try commit { catalog in
try catalog.renameGroup(id, to: trimmed)
}
}
/// 拖拽/移动菜单落点。跨来源、成环、移动进自身由领域拒绝,原样上抛呈现。
func move(_ id: NodeID, to parent: NodeID?) async throws {
try await commit { catalog in
try commit { catalog in
try catalog.move(id, to: parent)
}
}
/// 删除(手动分组递归删整棵子树),并尽力清理被删节点的 Keychain 秘密。
func remove(_ id: NodeID) async throws {
let removed: [CatalogEntry] = try await commit { catalog in
let removed: [CatalogEntry] = try commit { catalog in
try catalog.remove(id)
}
if let selection = selectedNodeID, removed.contains(where: { $0.id == selection }) {
@@ -215,7 +218,7 @@ final class CatalogViewModel: ObservableObject {
throw ServerFormError.unsupportedEncryptionMethod(trimmedMethod)
}
guard !password.isEmpty else { throw ServerFormError.invalidPassword }
try await commit { catalog in
try commit { catalog in
guard let entry = catalog.entry(for: id), case .server(var fields) = entry.kind else {
throw CatalogError.notAServer(id)
}
@@ -312,28 +315,23 @@ final class CatalogViewModel: ObservableObject {
// MARK: - 提交管线
/// 副本变更 → 落盘 → 发布 → postCommit;任一步失败则已发布状态不动。
/// 仅目录变更的提交便捷入口(订阅扩展使用双参 `commitDocument`)。
private func commit<T>(
_ mutate: (inout ConfigurationCatalog) throws -> T
) async throws -> T {
try await commitDocument { catalog, _ in
) throws -> T {
try commitDocument { catalog, _ in
try mutate(&catalog)
}
}
/// 目录 + 订阅记录同文档提交(订阅刷新/创建/删除共用):任一步失败则已
/// 发布状态不动,成功才依次发布并触发运行时重展开。
/// 副本变更 → 协调器落盘 → 发布已提交状态;任一步失败则已发布状态不动。
/// 运行时收敛由协调器异步调度(issue #40),不阻塞也不回滚本提交。
func commitDocument<T>(
_ mutate: (inout ConfigurationCatalog, inout [SubscriptionRecord]) throws -> T
) async throws -> T {
var workingCatalog = catalog
var workingSubscriptions = subscriptions
let result = try mutate(&workingCatalog, &workingSubscriptions)
try fileStore.save(
CatalogDocument(catalog: workingCatalog, subscriptions: workingSubscriptions))
catalog = workingCatalog
subscriptions = workingSubscriptions
await postCommit?()
) throws -> T {
let result = try coordinator.commit(mutate)
catalog = coordinator.committedCatalog
subscriptions = coordinator.committedSubscriptions
return result
}
+20 -27
View File
@@ -4,20 +4,32 @@ import SwiftUI
struct ShadowsocksXNG2App: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var proxyController: ProxyRuntimeController
@StateObject private var catalogViewModel = CatalogViewModel()
@StateObject private var catalogViewModel: CatalogViewModel
@StateObject private var loginController: LaunchAtLoginController
init() {
let settingsStore = ProxySettingsFileStore()
let restoredSettings = ProxySettingsFileStore.restored(store: settingsStore)
_proxyController = StateObject(
wrappedValue: ProxyRuntimeController(
listenRestore: RestoredListenSettings(
settings: restoredSettings.settings.listen, unreadableError: nil),
settingsStore: settingsStore,
settingsRestore: restoredSettings))
let controller = ProxyRuntimeController(
listenRestore: RestoredListenSettings(
settings: restoredSettings.settings.listen, unreadableError: nil),
settingsStore: settingsStore,
settingsRestore: restoredSettings)
_proxyController = StateObject(wrappedValue: controller)
let loginController = LaunchAtLoginController()
_loginController = StateObject(wrappedValue: loginController)
// 组合根(issue #40):目录提交协调器与生产运行时适配器只在此接线一次,
// 各 scene 不再重复设置提交回调;Legacy 导入边界同为一次性注入。
let coordinator = CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: CatalogFileStore.defaultFileURL()),
runtime: ProxyRuntimeSyncAdapter(controller: controller))
_catalogViewModel = StateObject(
wrappedValue: CatalogViewModel(
coordinator: coordinator,
postLegacyImport: { outcome in
await controller.legacyImportDidCommit()
loginController.applyImportedValue(outcome.loginAtLogin)
}))
Task { @MainActor in
loginController.syncAtLaunch()
}
@@ -30,17 +42,8 @@ struct ShadowsocksXNG2App: App {
MenuBarExtra("ShadowsocksX-NG 2.0", systemImage: "network") {
ProxyStatusMenu(controller: proxyController, catalogViewModel: catalogViewModel)
.task {
// postCommit 在应用启动即接线(不只主窗口打开时):菜单栏「立即更新
// 全部订阅」(issue #35)等未开窗路径的目录提交同样立即重展开运行时。
let controller = proxyController
catalogViewModel.postCommit = { await controller.catalogDidCommit() }
let loginController = loginController
catalogViewModel.postLegacyImport = { outcome in
await controller.legacyImportDidCommit()
loginController.applyImportedValue(outcome.loginAtLogin)
}
// 全局快捷键(issue #31):开关代理、切换模式,与菜单项同一入口。
GlobalShortcuts.wire(controller: controller)
GlobalShortcuts.wire(controller: proxyController)
await proxyController.resyncOnLaunch()
}
}
@@ -51,16 +54,6 @@ struct ShadowsocksXNG2App: App {
viewModel: catalogViewModel, proxyController: proxyController,
eventStore: .shared
)
.task {
// 目录提交 → 代理运行时立即重展开(spec #21 D3「已提交编辑立即跟随」)。
let controller = proxyController
catalogViewModel.postCommit = { await controller.catalogDidCommit() }
let loginController = loginController
catalogViewModel.postLegacyImport = { outcome in
await controller.legacyImportDidCommit()
loginController.applyImportedValue(outcome.loginAtLogin)
}
}
}
.defaultLaunchBehavior(
catalogViewModel.shouldOfferLegacyImport ? .automatic : .suppressed)
@@ -231,22 +231,6 @@ final class ProxyRuntimeController: ObservableObject {
await presentLaunchHealth(document)
}
/// 目录已提交变更后的立即重展开(D3/D5):有效非空且代理开启 → 原子更新
/// 运行时;目标失效 → 清除目标并停止代理。目录以磁盘为事实来源重载。
func catalogDidCommit() async {
reloadCatalog()
switch reexpand() {
case .deployed(let configuration):
if state != .off {
await deploy(configuration.document)
}
case .clearedAndStopped(let failure):
await handleCleared(failure)
case nil:
break
}
}
/// GUI 启动重同步(D5「GUI 下次启动重新校验同步」):注册态是代理意图的
/// 事实来源——注册过即视为开启并重校验;随后与磁盘契约对齐(相同内容跳
/// 过写入)。GUI 崩溃期间 agent 与 wrapper 均不受影响。
@@ -433,6 +417,44 @@ final class ProxyRuntimeController: ObservableObject {
}
extension ProxyRuntimeController {
/// 目录提交协调器的生产适配入口(issue #40):以刚提交的内存快照重展开,
/// 不回读磁盘(磁盘仍是重启与跨进程恢复的权威来源)。有效非空且代理开启
/// → 原子更新运行时;目标失效 → 清除目标并停止代理。返回结构化收敛结果,
/// 健康检查耗时属于本调用的异步收敛阶段,不改变「目录已提交」的事实。
func catalogDidCommit(snapshot catalog: ConfigurationCatalog) async -> RuntimeSyncOutcome {
self.catalog = catalog
switch reexpand() {
case .deployed(let configuration):
guard state != .off else {
return .revalidated
}
await deploy(configuration.document)
return Self.syncOutcome(for: state, skippedServers: configuration.skippedServers)
case .clearedAndStopped(let failure):
await handleCleared(failure)
return .clearedAndStopped(failure)
case nil:
return .revalidated
}
}
/// 部署后的控制器状态 → 结构化收敛结果:健康通过(含防火墙受阻的运行态)
/// 视为已收敛;启动、服务、系统代理与激活拒绝都视为未收敛并携带点名细节。
private static func syncOutcome(
for state: ProxyState, skippedServers: [SkippedServer]
) -> RuntimeSyncOutcome {
switch state {
case .running, .firewallBlocked:
.converged(skippedServers: skippedServers)
case .launchFailed(let detail), .serviceFailed(let detail), .systemProxyFailed(let detail):
.failed(detail: detail)
case .activationFailed(let reason):
.failed(detail: reason)
case .requiresApproval, .off, .starting:
.failed(detail: nil)
}
}
/// Persists a fully validated settings snapshot and, when the proxy is
/// active, re-derives the same runtime path with the new snapshot.
func updateSettings(_ next: ProxySettings) async throws {
@@ -0,0 +1,287 @@
import XCTest
@testable import ShadowsocksX_NG2
/// 确定性运行时同步替身(issue #40 测试缝):记录 converge 快照与次序,
/// 可编程每次结果;门闩让同步在收敛中挂起,用于验证提交代次语义。
@MainActor
final class FakeCatalogRuntime: CatalogRuntimeSyncing {
var hasActiveTarget = false
/// 未编程结果时的默认返回。
var defaultOutcome: RuntimeSyncOutcome = .converged(skippedServers: [])
private var outcomeQueue: [RuntimeSyncOutcome] = []
private(set) var convergeSnapshots: [CommittedCatalogSnapshot] = []
/// 门闩:武装后接下来的 converge 在记录调用后挂起,直到 openGate。
private var armedGateCount = 0
private var gateWaiters: [CheckedContinuation<Void, Never>] = []
var convergeCount: Int { convergeSnapshots.count }
/// 预编程按次序返回的结果(耗尽后回退 `defaultOutcome`)。
func enqueueOutcomes(_ outcomes: [RuntimeSyncOutcome]) {
outcomeQueue.append(contentsOf: outcomes)
}
func armGate(count: Int = 1) {
armedGateCount = count
}
func openGate() {
armedGateCount = 0
gateWaiters.forEach { $0.resume() }
gateWaiters.removeAll()
}
func converge(to snapshot: CommittedCatalogSnapshot) async -> RuntimeSyncOutcome {
convergeSnapshots.append(snapshot)
let result = outcomeQueue.isEmpty ? defaultOutcome : outcomeQueue.removeFirst()
if armedGateCount > 0 {
armedGateCount -= 1
await withCheckedContinuation { gateWaiters.append($0) }
}
return result
}
}
/// 让出 MainActor 直到条件成立(被测同步任务在同一线程上排队执行)。供
/// 协调器与视图模型测试共享:提交后的运行时收敛不再内联等待(issue #40)。
@MainActor
func waitUntilRuntimeSettles(
_ condition: @autoclosure () -> Bool,
timeout: TimeInterval = 2,
file: StaticString = #filePath,
line: UInt = #line
) async {
let deadline = Date().addingTimeInterval(timeout)
while !condition() && Date() < deadline {
await Task.yield()
}
XCTAssertTrue(condition(), "等待条件超时", file: file, line: line)
}
/// 目录提交—运行时同步协调器(issue #40):只断言外部行为——持久化与发布、
/// 运行时是否被调用及使用的快照、阶段与失败结果、并发提交的最终赢家、无效
/// 活动目标的停止语义。全部走临时目录文件存储,不触碰真实运行时。
@MainActor
final class CatalogCommitCoordinatorTests: XCTestCase {
private var workDir: URL!
private var fileURL: URL!
private var runtime: FakeCatalogRuntime!
private var coordinator: CatalogCommitCoordinator!
override func setUp() async throws {
try await super.setUp()
// 专用工作目录:写入器会把父目录强制 0700(不得指向临时根)。
workDir = FileManager.default.temporaryDirectory
.appendingPathComponent("catalog-commit-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
fileURL = workDir.appendingPathComponent("catalog.json")
runtime = FakeCatalogRuntime()
coordinator = makeCoordinator()
}
override func tearDown() async throws {
try? FileManager.default.removeItem(at: workDir)
try await super.tearDown()
}
private func makeCoordinator() -> CatalogCommitCoordinator {
CatalogCommitCoordinator(fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
}
/// 预置目录后重建协调器(模拟已落盘的既有状态)。
private func seedCatalog(_ mutate: (inout ConfigurationCatalog) throws -> Void) throws {
var catalog = ConfigurationCatalog()
try mutate(&catalog)
try CatalogFileStore(fileURL: fileURL).save(CatalogDocument(catalog: catalog))
coordinator = makeCoordinator()
}
private func persistedCatalog() throws -> ConfigurationCatalog {
try CatalogFileStore(fileURL: fileURL).load().catalog
}
// MARK: - 无活动目标
func testCommitWithoutActiveTargetPersistsAndPublishesWithoutRuntimeCall() throws {
runtime.hasActiveTarget = false
let added = try coordinator.commit { catalog, _ in
try catalog.addTestServer("香港 01")
}
XCTAssertTrue(coordinator.committedCatalog.contains(added), "提交后已发布新状态")
XCTAssertEqual(try persistedCatalog().rootChildren, [added], "变更已原子持久化")
XCTAssertEqual(runtime.convergeCount, 0, "无活动目标不驱动运行时")
XCTAssertEqual(coordinator.syncStatus, .idle)
}
// MARK: - 活动目标提交
func testCommitWithActiveServerTargetConvergesToCommittedSnapshot() async throws {
try seedCatalog { try $0.addTestServer("已有节点") }
runtime.hasActiveTarget = true
let added = try coordinator.commit { catalog, _ in
try catalog.addTestServer("新节点")
}
await waitUntilRuntimeSettles(runtime.convergeCount == 1)
XCTAssertEqual(runtime.convergeCount, 1, "一次提交恰好一次同步")
let snapshot = try XCTUnwrap(runtime.convergeSnapshots.first)
XCTAssertTrue(snapshot.catalog.contains(added), "同步使用刚提交的内存快照")
XCTAssertEqual(snapshot.subscriptions, [])
XCTAssertEqual(
coordinator.syncStatus,
.finished(generation: 1, outcome: .converged(skippedServers: [])))
}
func testCommitWithActiveGroupTargetUsesSamePipeline() async throws {
try seedCatalog { catalog in
let group = try catalog.addGroup("手动组")
_ = try catalog.addTestServer("成员", to: group)
}
runtime.hasActiveTarget = true
let groupID = try XCTUnwrap(coordinator.committedCatalog.rootChildren.first)
_ = try coordinator.commit { catalog, _ in
try catalog.addTestServer("新成员", to: groupID)
}
await waitUntilRuntimeSettles(runtime.convergeCount == 1)
XCTAssertEqual(runtime.convergeCount, 1, "分组目标与服务器目标共用同一提交语义")
let snapshot = try XCTUnwrap(runtime.convergeSnapshots.first)
let group = try XCTUnwrap(snapshot.catalog.entry(for: groupID))
guard case .group(let fields) = group.kind else {
return XCTFail("快照中的目标应为分组")
}
XCTAssertEqual(fields.children.count, 2, "同步快照携带刚提交的新子树")
}
func testMoveRemoveAndRenameShareCommitSemantics() async throws {
try seedCatalog { catalog in
let group = try catalog.addGroup("组")
_ = try catalog.addTestServer("成员", to: group)
}
runtime.hasActiveTarget = true
let groupID = try XCTUnwrap(coordinator.committedCatalog.rootChildren.first)
let serverID = try XCTUnwrap(coordinator.committedCatalog.children(of: groupID).first)
try coordinator.commit { catalog, _ in try catalog.move(serverID, to: nil) }
await waitUntilRuntimeSettles(runtime.convergeCount == 1)
try coordinator.commit { catalog, _ in try catalog.renameGroup(groupID, to: "改名") }
await waitUntilRuntimeSettles(runtime.convergeCount == 2)
try coordinator.commit { catalog, _ in try catalog.remove(serverID) }
await waitUntilRuntimeSettles(runtime.convergeCount == 3)
XCTAssertEqual(runtime.convergeCount, 3, "移动、改名、删除各触发一次同步")
XCTAssertEqual(
coordinator.syncStatus,
.finished(generation: 3, outcome: .converged(skippedServers: [])),
"最新代次的收敛结果是最终状态")
}
// MARK: - 无效活动目标
func testRemovedActiveTargetClearsAndStopsWithoutSilentFallback() async throws {
try seedCatalog { try $0.addTestServer("活动节点") }
let targetID = try XCTUnwrap(coordinator.committedCatalog.rootChildren.first)
runtime.hasActiveTarget = true
runtime.enqueueOutcomes([.clearedAndStopped(.targetNotFound(targetID))])
try coordinator.commit { catalog, _ in try catalog.remove(targetID) }
await waitUntilRuntimeSettles(coordinator.syncStatus != .syncing(generation: 1))
XCTAssertEqual(runtime.convergeCount, 1)
XCTAssertEqual(
coordinator.syncStatus,
.finished(generation: 1, outcome: .clearedAndStopped(.targetNotFound(targetID))),
"清除停止以结构化结果呈现")
XCTAssertFalse(try persistedCatalog().contains(targetID), "运行时失败不回滚目录")
}
// MARK: - 目录与运行时结果区分
func testRuntimeFailureKeepsCommittedCatalogAndDistinguishesResult() async throws {
runtime.hasActiveTarget = true
runtime.defaultOutcome = .failed(detail: "agent register failed")
let added = try coordinator.commit { catalog, _ in
try catalog.addTestServer("香港 01")
}
await waitUntilRuntimeSettles(coordinator.syncStatus != .syncing(generation: 1))
XCTAssertTrue(try persistedCatalog().contains(added), "运行时失败不回滚已提交目录")
XCTAssertEqual(
coordinator.syncStatus,
.finished(generation: 1, outcome: .failed(detail: "agent register failed")),
"结果明确区分目录已提交与运行时未收敛")
}
// MARK: - 提交代次
func testSupersededSyncBeforeStartNeverCallsRuntime() async throws {
runtime.hasActiveTarget = true
try coordinator.commit { catalog, _ in try catalog.addTestServer("第一台") }
try coordinator.commit { catalog, _ in try catalog.addTestServer("第二台") }
// 同一 MainActor 回合内的两次提交:旧代次同步尚未启动即被取代。
await waitUntilRuntimeSettles(runtime.convergeCount == 1)
XCTAssertEqual(runtime.convergeCount, 1, "被取代的旧代次不触碰运行时")
let snapshot = try XCTUnwrap(runtime.convergeSnapshots.first)
XCTAssertEqual(snapshot.catalog.rootChildren.count, 2, "最终同步采用最新提交快照")
XCTAssertEqual(
coordinator.syncStatus,
.finished(generation: 2, outcome: .converged(skippedServers: [])))
}
func testDelayedSyncCannotOverrideLatestSnapshotOrResult() async throws {
runtime.hasActiveTarget = true
runtime.enqueueOutcomes([
.converged(skippedServers: []),
.failed(detail: "最新代次的失败结果"),
])
runtime.armGate()
try coordinator.commit { catalog, _ in try catalog.addTestServer("第一台") }
await waitUntilRuntimeSettles(runtime.convergeCount == 1)
try coordinator.commit { catalog, _ in try catalog.addTestServer("第二台") }
runtime.openGate()
await waitUntilRuntimeSettles(runtime.convergeCount == 2)
await waitUntilRuntimeSettles(coordinator.syncStatus != .syncing(generation: 2))
XCTAssertEqual(runtime.convergeCount, 2)
XCTAssertEqual(
runtime.convergeSnapshots[1].catalog.rootChildren.count, 2, "最新代次使用最新快照")
XCTAssertEqual(
coordinator.syncStatus,
.finished(generation: 2, outcome: .failed(detail: "最新代次的失败结果")),
"迟到的旧代次结果不得覆盖最新代次")
}
// MARK: - 独立路径对齐
func testReloadCommittedStateAdoptsExternalWritesWithoutRuntimeCall() throws {
runtime.hasActiveTarget = true
// Legacy 导入等独立路径绕过协调器直接落盘(订阅固定分组须在目录内)。
var external = ConfigurationCatalog()
let groupID = NodeID(rawValue: "import:group")
try external.addGroup("导入订阅组", source: .subscription, id: groupID)
try CatalogFileStore(fileURL: fileURL).save(
CatalogDocument(
catalog: external,
subscriptions: [
SubscriptionRecord(id: .fresh(), groupID: groupID, urlRef: .fresh(), status: .never)
]))
coordinator.reloadCommittedStateFromStore()
XCTAssertEqual(coordinator.committedCatalog.rootChildren, [groupID], "已提交状态对齐磁盘")
XCTAssertEqual(coordinator.committedSubscriptions.count, 1)
XCTAssertEqual(runtime.convergeCount, 0, "对齐不触发运行时收敛")
}
}
@@ -43,12 +43,11 @@ final class CatalogViewModelPluginTests: XCTestCase {
}
private func makeViewModel() -> CatalogViewModel {
let model = CatalogViewModel(
fileStore: CatalogFileStore(fileURL: fileURL),
CatalogViewModel(
coordinator: CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: FakeCatalogRuntime()),
credentials: credentials,
plugins: BundleManagedPluginProvider(bundleURL: bundleRoot))
model.postCommit = {}
return model
}
/// 直建目录(不经 URI 导入)后重载视图模型,返回该服务器身份。
@@ -3,16 +3,17 @@ import XCTest
@testable import ShadowsocksX_NG2
/// 订阅端到端语义(issue #35 验收):创建门禁、刷新失败保留快照、overlay 与
/// 身份连续性、URL 编辑保身份、删除递归清除、UI 数据面与 postCommit 接线。
/// 获取走 FakeSubscriptionFetcher(传输侧契约在 SubscriptionFetcherTests)。
/// 身份连续性、URL 编辑保身份、删除递归清除、UI 数据面与提交接线。
/// 获取走 FakeSubscriptionFetcher(传输侧契约在 SubscriptionFetcherTests);
/// 提交后的运行时收敛以确定性 fake 计数(issue #40)。
@MainActor
final class CatalogViewModelSubscriptionTests: XCTestCase {
private var workDir: URL!
private var fileURL: URL!
private var credentials: InMemoryCredentialStore!
private var fetcher: FakeSubscriptionFetcher!
private var runtime: FakeCatalogRuntime!
private var viewModel: CatalogViewModel!
private var commitCounter: CommitCounter!
override func setUp() async throws {
try await super.setUp()
@@ -21,7 +22,7 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
fileURL = workDir.appendingPathComponent("catalog.json")
credentials = InMemoryCredentialStore()
commitCounter = CommitCounter()
runtime = FakeCatalogRuntime()
fetcher = FakeSubscriptionFetcher(behavior: .success(SubscriptionDocs.flat(serverCount: 1)))
viewModel = makeViewModel()
}
@@ -32,13 +33,15 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
}
private func makeViewModel() -> CatalogViewModel {
// 计数即提交计数:提交后的运行时收敛由协调器异步调度(issue #40)。
runtime.hasActiveTarget = true
let coordinator = CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
let model = CatalogViewModel(
fileStore: CatalogFileStore(fileURL: fileURL),
coordinator: coordinator,
credentials: credentials,
plugins: NoManagedPluginProvider())
model.subscriptionFetcher = fetcher!
let counter = commitCounter!
model.postCommit = { await counter.increment() }
return model
}
@@ -123,7 +126,7 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
fetcher = FakeSubscriptionFetcher(behavior: .success(treeDoc))
viewModel = makeViewModel()
let record = try await viewModel.createSubscription(urlString: "https://p.example.com/s.json")
let commitsBefore = await commitCounter.count
let convergesBefore = runtime.convergeCount
fetcher = FakeSubscriptionFetcher(behavior: .failure(.httpStatus(code: 503)))
viewModel.subscriptionFetcher = fetcher
@@ -139,8 +142,8 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
}
XCTAssertTrue(reason.contains("503"))
// 仅状态提交了一次(快照未动)。
let commitsAfter = await commitCounter.count
XCTAssertEqual(commitsAfter - commitsBefore, 1)
await waitUntilRuntimeSettles(runtime.convergeCount - convergesBefore == 1)
XCTAssertEqual(runtime.convergeCount - convergesBefore, 1)
}
func testDecodeAndSchemaFailuresMarkFailedWithoutTouchingSnapshot() async throws {
@@ -301,7 +304,7 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
let record = try await seedSuccessfulSubscription()
viewModel.selectedNodeID = serverAID
let passwordRef = try XCTUnwrap(serverFields(of: serverAID)).passwordRef
let commitsBefore = await commitCounter.count
let convergesBefore = runtime.convergeCount
try await viewModel.removeSubscription(record.id)
@@ -312,8 +315,8 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
XCTAssertNil(viewModel.selectedNodeID, "选中节点随子树清除")
XCTAssertNil(try credentials.secret(for: passwordRef), "远端成员凭据一并清理")
XCTAssertNil(try credentials.secret(for: record.urlRef), "订阅 URL 凭据一并清理")
let commitsAfter = await commitCounter.count
XCTAssertEqual(commitsAfter - commitsBefore, 1, "删除经 postCommit 触发活动目标重展开接线")
await waitUntilRuntimeSettles(runtime.convergeCount - convergesBefore == 1)
XCTAssertEqual(runtime.convergeCount - convergesBefore, 1, "删除经提交协调器触发活动目标重展开")
}
// MARK: 批量刷新与持久化
@@ -322,7 +325,7 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
_ = try await seedSuccessfulSubscription()
_ = try await createOnly(urlString: "https://q.example.com/t.json")
let requestsBefore = fetcher.requestCount
let commitsBefore = await commitCounter.count
let convergesBefore = runtime.convergeCount
await viewModel.refreshAllSubscriptions()
@@ -332,15 +335,16 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
return XCTFail("全部订阅都应记成功态:\(record.id)")
}
}
let commitsAfter = await commitCounter.count
XCTAssertEqual(commitsAfter - commitsBefore, 2)
await waitUntilRuntimeSettles(runtime.convergeCount - convergesBefore == 2)
XCTAssertEqual(runtime.convergeCount - convergesBefore, 2)
}
func testSubscriptionsPersistAcrossViewModelReload() async throws {
let record = try await seedSuccessfulSubscription()
let reloaded = CatalogViewModel(
fileStore: CatalogFileStore(fileURL: fileURL),
coordinator: CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: FakeCatalogRuntime()),
credentials: credentials,
plugins: NoManagedPluginProvider())
XCTAssertEqual(reloaded.subscriptions.count, 1)
@@ -3,14 +3,15 @@ import XCTest
@testable import ShadowsocksX_NG2
/// 主窗口视图模型(issue #32):三入口导入、凭据生命周期、订阅子树只读夹具、
/// 分享互逆与 postCommit 接线。全部走内存凭据存储与临时目录文件存储。
/// 分享互逆与提交接线。全部走内存凭据存储与临时目录文件存储;提交后的运行
/// 时收敛以确定性 fake 计数(issue #40)。
@MainActor
final class CatalogViewModelTests: XCTestCase {
private var workDir: URL!
private var fileURL: URL!
private var credentials: InMemoryCredentialStore!
private var runtime: FakeCatalogRuntime!
private var viewModel: CatalogViewModel!
private var commitCounter: CommitCounter!
override func setUp() async throws {
try await super.setUp()
@@ -20,7 +21,7 @@ final class CatalogViewModelTests: XCTestCase {
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
fileURL = workDir.appendingPathComponent("catalog.json")
credentials = InMemoryCredentialStore()
commitCounter = CommitCounter()
runtime = FakeCatalogRuntime()
viewModel = makeViewModel()
}
@@ -30,13 +31,14 @@ final class CatalogViewModelTests: XCTestCase {
}
private func makeViewModel() -> CatalogViewModel {
let model = CatalogViewModel(
fileStore: CatalogFileStore(fileURL: fileURL),
// 计数即提交计数:提交后的运行时收敛由协调器异步调度(issue #40)。
runtime.hasActiveTarget = true
let coordinator = CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
return CatalogViewModel(
coordinator: coordinator,
credentials: credentials,
plugins: NoManagedPluginProvider())
let counter = commitCounter!
model.postCommit = { await counter.increment() }
return model
}
private func makeSubscriptionCatalog() throws {
@@ -137,8 +139,14 @@ final class CatalogViewModelTests: XCTestCase {
XCTAssertEqual(fields.remark, "改过的")
XCTAssertEqual(fields.passwordRef, oldRef, "同一引用始终对应最新秘密")
XCTAssertEqual(try credentials.secret(for: oldRef), "新密码")
let commitCount = await commitCounter.count
XCTAssertEqual(commitCount, 2, "导入 + 更新各一次提交")
// 连续提交合并为最新代次的收敛(issue #40):旧代次在启动前被取代,
// 最终结果必为最新提交快照。
await waitUntilRuntimeSettles(viewModel.coordinator.syncStatus != .syncing(generation: 2))
XCTAssertEqual(
viewModel.coordinator.syncStatus,
.finished(generation: 2, outcome: .converged(skippedServers: [])))
XCTAssertFalse(runtime.convergeSnapshots.isEmpty)
XCTAssertEqual(runtime.convergeSnapshots.last?.catalog, viewModel.catalog)
}
func testUpdateServerRejectsInvalidAddressAndPort() async throws {
@@ -251,12 +259,6 @@ final class CatalogViewModelTests: XCTestCase {
}
}
/// postCommit 触发计数。
actor CommitCounter {
private(set) var count = 0
func increment() { count += 1 }
}
/// `XCTAssertThrowsError` 的 async 版本(表达式在 await 之后才能检查)。
func expectThrowsAsync(
_ expression: () async throws -> Void,
@@ -336,7 +336,9 @@ final class ProxyRuntimeControllerTests: XCTestCase {
try catalog.updateServer(seeded.server, with: fields)
try CatalogFileStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog))
await controller.catalogDidCommit()
// 协调器生产适配入口(issue #40):以刚提交的内存快照重展开。
let committed = try CatalogFileStore(fileURL: catalogFileURL).load().catalog
await controller.catalogDidCommit(snapshot: committed)
XCTAssertEqual(controller.state, .running)
let expectedRemark = "新加坡 01"
@@ -359,7 +361,9 @@ final class ProxyRuntimeControllerTests: XCTestCase {
try catalog.remove(seeded.server)
try CatalogFileStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog))
await controller.catalogDidCommit()
// 协调器生产适配入口(issue #40):以刚提交的内存快照重展开。
let committed = try CatalogFileStore(fileURL: catalogFileURL).load().catalog
await controller.catalogDidCommit(snapshot: committed)
guard case .activationFailed = controller.state else {
XCTFail("应呈现点名原因,实际 \(controller.state)")