From d7169d9f012340a03e3a3cdbc482bc766186993e Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sat, 3 Oct 2026 11:08:24 +0800 Subject: [PATCH] refactor(runtime): route catalog sync through the convergence spine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - catalogDidCommit 的 deployed 分支体(派生 + 幂等跳过 + deployPrepared) 与 deploy() 逐语义等价,改走 deploy();runtimeAlreadyConverged 独立机制 删除,plan 空判定由 apply 层的 convergencePlanIsEmpty 统一承担。 - deploy/deployPrepared 返回 ConvergenceResult:被取代的派生按 .superseded 呈现为 .revalidated,其余路径仍由状态面映射。 - reexpand 三-case 开关收敛为 convergeToReexpanded:convergeAgent / updateSettings / resyncOnLaunch 各变一行;catalogDidCommit 保留按 case 的结构化 outcome 映射。 - 统一设置持久化 epilogue 为 persistSettings:setAgentEnabled / setSystemProxyEnabled / transitionMode 三处相同 save-fail 语义折叠 (updateSettings 的 throw 变体与级联关闭的 return-nil 变体语义不同, 保持原样)。 - SkeletonTests 运行时适配器豁免名单登记 +Convergence.swift。 --- .../App/ProxyRuntimeController+Commands.swift | 56 ++++++------------- .../ProxyRuntimeController+Convergence.swift | 31 +++++++++- .../App/ProxyRuntimeController+Mode.swift | 8 +-- .../ProxyRuntimeController+SettingsSync.swift | 48 ++-------------- ShadowsocksX-NG2/Tests/SkeletonTests.swift | 3 +- 5 files changed, 54 insertions(+), 92 deletions(-) diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift index 1180969d..b2d05711 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift @@ -57,13 +57,7 @@ extension ProxyRuntimeController { } var next = settings next.agentEnabled = enabled - do { - try settingsStore.save(next) - } catch { - RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error))) - state = .serviceFailed(.persistence) - return - } + guard persistSettings(next) else { return } settings = next if enabled { await convergeAgent() @@ -103,13 +97,7 @@ extension ProxyRuntimeController { guard enabled != settings.systemProxyEnabled else { return } var next = settings next.systemProxyEnabled = enabled - do { - try settingsStore.save(next) - } catch { - RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error))) - state = .serviceFailed(.persistence) - return - } + guard persistSettings(next) else { return } settings = next if enabled { systemProxyInitialApplyPending = true @@ -139,14 +127,7 @@ extension ProxyRuntimeController { return } let catalog = catalogSnapshotReader.catalogSnapshot - switch reexpand(in: catalog) { - case .deployed(let configuration): - await deploy(configuration.document) - case .clearedAndStopped(let failure): - await handleCleared(failure) - case nil: - await deployListeningWithoutTarget() - } + await convergeToReexpanded(catalog) systemProxyStartupInProgress = false if settings.systemProxyEnabled { lastDesiredSystemProxyConfiguration = desiredSystemProxyConfiguration @@ -162,15 +143,7 @@ extension ProxyRuntimeController { /// Agent 意图开启的收敛:注册态不是事实来源,意图才是——未注册也会注册, /// 已注册则重校验目标并部署。偏好重置后也走此路径,把运行时收敛到出厂设置。 func convergeAgent() async { - let catalog = catalogSnapshotReader.catalogSnapshot - switch reexpand(in: catalog) { - case .deployed(let configuration): - await deploy(configuration.document) - case .clearedAndStopped(let failure): - await handleCleared(failure) - case nil: - await deployListeningWithoutTarget() - } + await convergeToReexpanded(catalogSnapshotReader.catalogSnapshot) } /// Agent 意图关闭的收敛(issue #71):只停止本地监听,不做系统代理清理。 @@ -203,38 +176,41 @@ extension ProxyRuntimeController { await deploy(document) } - /// 把「运行定义档」意图推到运行时:计划动作 → 顺序执行 → 端点健康呈现。 + /// 把「运行定义档」意图推到运行时:派生文档 → 收敛脊柱 apply(幂等跳过、 + /// 计划执行、健康门)。 @discardableResult - func deploy(_ sourceDocument: SslocalRuntimeDocument) async -> Bool { + func deploy(_ sourceDocument: SslocalRuntimeDocument) async -> ConvergenceResult { if listenSettingsUnreadable || settingsUnreadable { await refuseDeployForUnreadableListenSettings() - return false + return .failed } let contract: PreparedRuntimeContract do { contract = try PreparedRuntimeContract(await runtimeDocument(sourceDocument, for: proxyMode)) } catch RulePreparationError.superseded { - return false + return .superseded } catch { // 规则快照缺失/损坏:不静默退化成全局(issue #63 AC4)。 RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error))) state = .serviceFailed(.runtimeFile) - return false + return .failed } return await deployPrepared(contract, preparation: runtimePreparationGeneration) } - func deployPrepared(_ contract: PreparedRuntimeContract, preparation: Int) async -> Bool { - guard preparation == runtimePreparationGeneration else { return false } + @discardableResult + func deployPrepared( + _ contract: PreparedRuntimeContract, preparation: Int + ) async -> ConvergenceResult { + guard preparation == runtimePreparationGeneration else { return .superseded } let document = contract.document var ticket = convergenceTicket() - let result = await apply( + return await apply( document, ticket: &ticket, checking: [.preparation], requiresReceipt: document.aclRuntime != nil, convergeProxyOnSuccess: true, preserveProxyOnFailure: false, proxyTail: .none, rollback: nil) - return result != .failed } /// D8「任何路径不静默改端口」:监听设置不可读时以占位出厂端口部署等于 diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Convergence.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Convergence.swift index 563a32a9..a29a4be7 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Convergence.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Convergence.swift @@ -64,7 +64,8 @@ extension ProxyRuntimeController { /// 派生文档对当前运行时是否无事可做(CONTEXT.md「有效值未变的提交不做 /// 运行时收敛」):磁盘契约与派生字节逐位相同、agent 已注册且 wrapper /// 存活、控制器处于健康运行态——计划层动作序列为空即幂等跳过。判定在 - /// 置 `starting` 之前进行,跳过路径不闪状态、不重走健康门。 + /// 置 `starting` 之前进行,跳过路径不闪状态、不重走健康门;磁盘漂移或 + /// wrapper 失踪时动作序列自然非空,走完整 deploy 自愈。 func convergencePlanIsEmpty( _ document: SslocalRuntimeDocument, preparedContract: PreparedRuntimeContract? ) -> Bool { @@ -82,6 +83,34 @@ extension ProxyRuntimeController { return actions.isEmpty } + /// 统一的设置持久化 epilogue:保存失败不静默偏离持久化事实——记录日志、 + /// 呈现 `serviceFailed(.persistence)`,调用方放弃本次收敛。 + func persistSettings(_ proposed: ProxySettings) -> Bool { + do { + try settingsStore.save(proposed) + return true + } catch { + RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error))) + state = .serviceFailed(.persistence) + return false + } + } + + /// reexpand 三-case 开关的收敛主体(显式收敛、设置保存、启动重同步共用): + /// deployed → 部署派生文档;目标失效 → 清除并按意图收敛;无目标 → 空监听。 + /// 幂等跳过由 apply 层的 plan 空判定承担;目录重校验(跳过名单、目标失效 + /// 清除)已在 `reexpand` 完成,不受其影响。 + func convergeToReexpanded(_ catalog: ConfigurationCatalog) async { + switch reexpand(in: catalog) { + case .deployed(let configuration): + await deploy(configuration.document) + case .clearedAndStopped(let failure): + await handleCleared(failure) + case nil: + await deployListeningWithoutTarget() + } + } + /// 收敛脊柱 apply 层的结果。 enum ConvergenceResult { /// 部署已执行且启动健康通过。 diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift index dec32284..29573e5f 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift @@ -41,13 +41,7 @@ extension ProxyRuntimeController { var next = settings next.preferredMode = mode.kind next.ruleDefaultAction = ruleDefaultAction - do { - try settingsStore.save(next) - } catch { - RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error))) - state = .serviceFailed(.persistence) - return - } + guard persistSettings(next) else { return } settings = next proxyMode = mode modeChangeGeneration += 1 diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+SettingsSync.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+SettingsSync.swift index 4362b1af..669e1fd8 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+SettingsSync.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+SettingsSync.swift @@ -7,26 +7,17 @@ extension ProxyRuntimeController { /// 不回读磁盘(磁盘仍是重启与跨进程恢复的权威来源)。agent 意图开启时 /// 原子更新运行时;目标失效 → 清除目标但 agent 继续监听。返回结构化收敛 /// 结果,健康检查耗时属于本调用的异步收敛阶段,不改变「目录已提交」的事实。 + /// 「有效值未变的提交不做运行时收敛」由收敛脊柱 apply 层的 plan 空判定 + /// 承担(`convergencePlanIsEmpty`);派生被更新的收敛取代时按重校验呈现。 func catalogDidCommit(snapshot catalog: ConfigurationCatalog) async -> RuntimeSyncOutcome { switch reexpand(in: catalog) { case .deployed(let configuration): guard settings.agentEnabled else { return .revalidated } - do { - let document = try await runtimeDocument(configuration.document, for: proxyMode) - let preparation = runtimePreparationGeneration - let contract = try PreparedRuntimeContract(document) - if runtimeAlreadyConverged(with: contract) { - RuntimeLog.emit(.contractUnchanged) - } else { - _ = await deployPrepared(contract, preparation: preparation) - } - } catch RulePreparationError.superseded { + let result = await deploy(configuration.document) + if case .superseded = result { return .revalidated - } catch { - RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error))) - state = .serviceFailed(.runtimeFile) } return Self.syncOutcome( agentState: state, systemProxyState: systemProxyState, @@ -50,28 +41,6 @@ extension ProxyRuntimeController { } } - /// 目录提交去抖(CONTEXT.md「有效值未变的提交不做运行时收敛」):派生文档 - /// (含模式/ACL 合并)与磁盘契约逐字节相等、agent 已注册且 wrapper 存活、 - /// 控制器处于健康运行态时,本次提交对运行时无事可做——不写契约、不发信号、 - /// 不闪 starting、不重走健康门与系统代理收敛。判定复用计划层语义 - /// (`ProxyRuntimePlan.actions` 返回空 = 幂等跳过):磁盘漂移或 wrapper 失踪 - /// 时动作序列自然非空,走完整 deploy 自愈。目录重校验(跳过名单、目标失效 - /// 清除)已在 `reexpand` 完成,不受此门影响。 - private func runtimeAlreadyConverged(with contract: PreparedRuntimeContract) -> Bool { - switch state { - case .running, .firewallBlocked: - break - case .off, .starting, .launchFailed, .requiresApproval, .serviceFailed: - return false - } - let actions = ProxyRuntimePlan.actions( - intent: .run(contract.document), - agentStatus: agent.status, - wrapper: wrapperState(), - contractOnDisk: runtimeFileStore.readData(), preparedContract: contract) - return actions.isEmpty - } - /// 部署后的控制器状态 → 结构化收敛结果:健康通过(含防火墙受阻的运行态) /// 视为已收敛;系统代理写入失败携带点名细节,待应用不算目录收敛失败。 private static func syncOutcome( @@ -103,14 +72,7 @@ extension ProxyRuntimeController { listenSettingsUnreadable = false settingsUnreadable = false guard state != .off else { return } - switch reexpand(in: catalog) { - case .deployed(let configuration): - await deploy(configuration.document) - case .clearedAndStopped(let failure): - await handleCleared(failure) - case nil: - await deployListeningWithoutTarget() - } + await convergeToReexpanded(catalog) } /// Applies the post-import 2.0 runtime boundary without touching diff --git a/ShadowsocksX-NG2/Tests/SkeletonTests.swift b/ShadowsocksX-NG2/Tests/SkeletonTests.swift index ff08a600..75671d0f 100644 --- a/ShadowsocksX-NG2/Tests/SkeletonTests.swift +++ b/ShadowsocksX-NG2/Tests/SkeletonTests.swift @@ -61,9 +61,10 @@ final class CatalogWorkflowArchitectureTests: XCTestCase { "MainApp.swift", "CatalogCommitCoordinator.swift", "ProxyRuntimeController.swift", - // 控制器按命令面/设置与目录同步/系统代理门禁/事实投影分文件(同属运行时适配器)。 + // 控制器按命令面/设置与目录同步/收敛脊柱/系统代理门禁/事实投影分文件(同属运行时适配器)。 "ProxyRuntimeController+Commands.swift", "ProxyRuntimeController+SettingsSync.swift", + "ProxyRuntimeController+Convergence.swift", "ProxyRuntimeController+SystemProxyGate.swift", "ProxyRuntimeController+Facts.swift", "SystemProxyHelperService.swift",