Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/ProxyRuntimeController+Convergence.swift
T
Qiu Yuzhou 16a2384498 fix(runtime): 收敛票据恒查全部失效事实,agent 关闭不再被旧收敛复活
mode/catalog 路径的票据子集缺 .flow/.agentEnabled:agent 关闭落在
mode 切换健康门 await 窗口内会被误分类为失败并触发 restore,把用户
显式的 off 意图静默撤销。删除逐 call site 挑子集的 Checks 政策,
convergenceIsCurrent 恒查 flow/mode/preparation/agentEnabled 四事实;
deployPrepared 的手工 preparation 守卫随之删除。

restore 门禁先行:agent 意图与票据时效先于回滚载荷持久化——竞态下
不再把旧 mode 写回持久层(内存/持久层分叉、用户切换被无声撤销)。

轮询写入守卫对齐同一票据词汇:防火墙轮询 await 恢复后补查代际
(cancel 后不得把 .firewallBlocked 盖回 .off);系统代理健康 Task
post-await 守卫补 agentEnabled。

新增 5 个交错/门禁测试(BlockingProbe/BlockingFirewallChecker 暂停
缝;修复前实测全红:agent 重注册、running 复活、.direct 意图被改回
.global、载荷写回、.failed 误分类);CONTEXT.md 增补 Convergence
ticket 词条。全量 792 测试通过。
2026-10-03 18:44:58 +08:00

266 lines
12 KiB
Swift

import Foundation
/// 一次计划执行的结果:动作是否全部按序成功,以及本次执行占用的并发流
/// 代际。占用代际供收敛票据推进 flow 分量,取代调用方对 execute 内部
/// 「恰好自增一次」时序的依赖(原 `flowGeneration + 1` 手算)。
struct RuntimeExecutionOutcome {
/// 本次执行占用的 `flowGeneration`。
let flow: Int
/// 全部计划动作是否按序成功。
let succeeded: Bool
}
/// 收敛票据:运行时收敛操作的代际快照。入口捕获三个并发代际,之后每步
/// 推进用它判定「从入口到现在,并发流 / 模式切换 / 运行时派生是否前进了,
/// 会话内 agent 意图是否仍开启」;被更新提交或新意图取代的旧收敛不得再
/// 变更运行时状态或写契约。
///
/// 四项失效事实由票据恒全查,不随调用路径增减——「什么会使一次收敛失效」
/// 是票据的固有语义(逐路径手选子集曾让 agent 关闭在 mode 切换的健康门
/// await 窗口内被误分类为失败并触发复活性的 restore)。各路径只负责捕获
/// 与推进时机:flow 分量在 execute 后以实际占用值推进,mode 切换路径在
/// 派生后推进 preparation 分量(派生自身会推进它),rules 部署在推进 mode
/// 代际之后捕获。
struct ConvergenceTicket: Equatable {
/// 并发流代际;每次 execute 之后由调用方以实际占用值推进。
var flow: Int
/// 模式切换代际;捕获后不再变化。
let mode: Int
/// 运行时文档派生代际;每次派生之后由调用方以当前值推进。
var preparation: Int
}
extension ProxyRuntimeController {
/// 以当前代际捕获收敛票据。
func convergenceTicket() -> ConvergenceTicket {
ConvergenceTicket(
flow: flowGeneration, mode: modeChangeGeneration,
preparation: runtimePreparationGeneration)
}
/// 票据是否仍代表当前收敛:并发流 / 模式切换 / 派生三个代际均未前进,
/// 且会话内 agent 意图仍开启。
func convergenceIsCurrent(_ ticket: ConvergenceTicket) -> Bool {
ticket.flow == flowGeneration && ticket.mode == modeChangeGeneration
&& ticket.preparation == runtimePreparationGeneration
&& settings.agentEnabled
}
/// 派生文档对当前运行时是否无事可做(CONTEXT.md「有效值未变的提交不做
/// 运行时收敛」):磁盘契约与派生字节逐位相同、agent 已注册且 wrapper
/// 存活、控制器处于健康运行态——计划层动作序列为空即幂等跳过。判定在
/// 置 `starting` 之前进行,跳过路径不闪状态、不重走健康门;磁盘漂移或
/// wrapper 失踪时动作序列自然非空,走完整 deploy 自愈。
func convergencePlanIsEmpty(
_ document: SslocalRuntimeDocument, preparedContract: PreparedRuntimeContract?
) -> Bool {
switch state {
case .running, .firewallBlocked:
break
case .off, .starting, .launchFailed, .requiresApproval, .serviceFailed:
return false
}
let actions = ProxyRuntimePlan.actions(
intent: .run(document),
agentStatus: agent.status,
wrapper: wrapperState(),
contractOnDisk: runtimeFileStore.readData(), preparedContract: preparedContract)
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 {
/// 部署已执行且启动健康通过。
case applied
/// 派生契约与运行中运行时逐字节相同:不写契约、不发信号、不闪
/// starting、不重走健康门与系统代理收敛(幂等跳过)。
case unchanged
/// 执行或健康门失败;回滚由调用方按 RollbackPlan 处理。
case failed
/// 被更新的收敛取代:不得再变更任何状态。
case superseded
}
/// 成功收敛后的系统代理收尾方式。
enum ProxyConvergenceTail {
/// 强制重收敛(mode 切换:系统代理值随模式 ACL 变化)。
case forceApply
/// 常规收敛(rules 提交)。
case converge
/// 无需收尾(deploy 路径由健康门自身的 convergeProxyOnSuccess 收敛)。
case none
}
/// 收敛脊柱 apply 层:把一个已派生的运行时定义推到运行时——幂等跳过、
/// 计划执行、启动健康门、成功后按参数收敛系统代理。失败不在此回滚:
/// 返回 `.failed` 由调用方按 RollbackPlan 整体恢复。
///
/// 票据 inout 传参:execute / 派生后的代际推进对调用方可见,调用方
/// 在 apply 之后的时效检查使用的是推进过的票据。
///
/// `starting` 在 execute 之前置位:`.run` 计划的动作全部同步执行,二者
/// 之间不存在可见的挂起点,与旧路径「deploy 后置位」不可区分。
func apply(
_ document: SslocalRuntimeDocument,
ticket: inout ConvergenceTicket,
requiresReceipt: Bool,
convergeProxyOnSuccess: Bool,
preserveProxyOnFailure: Bool,
proxyTail: ProxyConvergenceTail,
rollback: RollbackPlan?
) async -> ConvergenceResult {
let contract = try? PreparedRuntimeContract(document)
if convergencePlanIsEmpty(document, preparedContract: contract) {
RuntimeLog.emit(.contractUnchanged)
ruleApplicationFailure = nil
return .unchanged
}
guard convergenceIsCurrent(ticket) else { return .superseded }
lastDocument = document
state = .starting
let execution = await execute(.run(document), document: document, preparedContract: contract)
ticket.flow = execution.flow
guard convergenceIsCurrent(ticket) else { return .superseded }
guard execution.succeeded else { return .failed }
let healthy = await presentLaunchHealth(
document,
requiresReceipt: requiresReceipt,
convergeProxyOnSuccess: convergeProxyOnSuccess,
preserveProxyOnFailure: preserveProxyOnFailure, preparedContract: contract)
guard convergenceIsCurrent(ticket) else { return .superseded }
guard healthy else { return .failed }
lastDocument = document
ruleApplicationFailure = nil
switch proxyTail {
case .forceApply: await systemProxyObserver.convergeSystemProxy(forceApply: true)
case .converge: await systemProxyObserver.convergeSystemProxy()
case .none: break
}
return .applied
}
/// 按回滚计划整体恢复:先查时效(票据含会话内 agent 意图)再动任何事实——
/// 被取代或 agent 已被用户关闭的回滚不得把旧载荷写回持久层、也不得把
/// 旧文档重新拉起;随后重持久化载荷 → 计划执行拉回旧文档(含按需拉起,
/// 由计划层对注销/信号/注册的裁决保证与旧 restore 例程等价)→ 启动健康
/// 门。载荷持久化失败不阻断文档恢复;文档恢复失败或健康不过即撤下系统
/// 代理意图。
func restore(_ plan: RollbackPlan, ticket: inout ConvergenceTicket) async -> RestoreReport {
guard convergenceIsCurrent(ticket) else {
return RestoreReport(payloadFailureDescription: nil, runtimeHealthy: nil)
}
var payloadFailureDescription: String?
var restoredSettingsForMemory: ProxySettings?
var restoredMode: ProxyMode?
switch plan.payload {
case .modeTransition(let mode, let ruleDefaultAction):
// 按快照还原偏好(模式与子选项回退,其余字段保留当前值)。
var restored = settings
restored.preferredMode = mode.kind
restored.ruleDefaultAction = ruleDefaultAction
do {
try settingsStore.save(restored)
} catch {
payloadFailureDescription = String(describing: error)
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
}
restoredSettingsForMemory = restored
restoredMode = mode
case .runtimeOnly:
break
}
func report(_ healthy: Bool?) -> RestoreReport {
RestoreReport(
payloadFailureDescription: payloadFailureDescription, runtimeHealthy: healthy)
}
if let restoredSettingsForMemory { settings = restoredSettingsForMemory }
if let restoredMode { proxyMode = restoredMode }
lastDocument = plan.document
state = .starting
let contract = try? PreparedRuntimeContract(plan.document)
let execution = await execute(
.run(plan.document), document: plan.document, preparedContract: contract)
ticket.flow = execution.flow
guard convergenceIsCurrent(ticket) else { return report(nil) }
guard execution.succeeded else {
await systemProxyObserver.holdSystemProxyIntent()
return report(false)
}
let healthy = await presentLaunchHealth(
plan.document, requiresReceipt: true, convergeProxyOnSuccess: false,
preparedContract: contract)
guard convergenceIsCurrent(ticket) else { return report(nil) }
if healthy {
if plan.convergeProxyOnIntentChange,
settings.systemProxyEnabled != plan.systemProxyIntentAtCapture
{
await systemProxyObserver.convergeSystemProxy(forceApply: true)
} else {
state = plan.state
}
} else {
await systemProxyObserver.holdSystemProxyIntent()
}
return report(healthy)
}
}
/// 收敛失败时的整体回滚计划:恢复到哪个运行时文档、健康通过后呈现哪个
/// 状态、以及除文档外还要重持久化的载荷。
struct RollbackPlan {
/// 回滚载荷:除运行时文档外还要恢复的持久化事实。
enum Payload {
/// mode 切换:设置快照的模式与规则子选项回退(其余字段保留当前值),
/// 会话内 proxyMode 一并还原。
case modeTransition(mode: ProxyMode, ruleDefaultAction: RuleDefaultAction)
/// Rule application restores only the previous runtime, never saved intent.
case runtimeOnly
}
/// 回滚目标文档(调用方已用当次加载值补齐)。
let document: SslocalRuntimeDocument
/// 健康通过后呈现的状态(收敛开始前捕获)。
let state: ProxyRuntimeController.AgentRunState
let payload: Payload
/// 捕获时的系统代理意图:恢复健康后若当前意图已变化,强制重收敛一次。
let systemProxyIntentAtCapture: Bool
/// 是否在意图变化时强制重收敛(mode 切换路径;rules 路径为 false)。
let convergeProxyOnIntentChange: Bool
}
/// 回滚报告:载荷重持久化与旧运行时恢复的结果。
struct RestoreReport {
/// 载荷持久化失败的点名描述;nil = 成功。
let payloadFailureDescription: String?
/// 旧运行时是否恢复到健康;nil = 恢复未尝试(被弃权或被取代)。
let runtimeHealthy: Bool?
}