diff --git a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Health.swift b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Health.swift index 67e295ba..1c7c2ce4 100644 --- a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Health.swift +++ b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+Health.swift @@ -214,10 +214,8 @@ extension ProxyRuntimeController { } extension ProxyRuntimeController { - /// Uses the existing local endpoint/receipt health seam while the GUI is alive. - /// Network path availability is deliberately absent from this decision. - /// 任务柄与循环调度在观察机(scheduleSystemProxyHealthObservation);循环体 - /// 留在本体——它同时承担运行时健康呈现,系统代理反应转发给观察机。 + /// Agent 独占稳定运行期间的故障判定;GUI 只读取明确失败,避免重复连接入站。 + /// 任务柄仍由系统代理观察机持有,并保留批准状态与待首次应用的刷新。 func runSystemProxyHealthObservationLoop() async { while !Task.isCancelled { try? await Task.sleep(nanoseconds: systemProxyHealthPollIntervalNanoseconds) @@ -230,46 +228,15 @@ extension ProxyRuntimeController { !systemProxyObserver.systemProxyStartupInProgress, settings.agentEnabled, let document = runtimeObservation else { continue } - await inspectSystemProxyRuntimeHealth(document) - } - } - - private func inspectSystemProxyRuntimeHealth(_ document: RuntimeDeploymentFacts) async { - if runtimeFileStore.blocksDeployment(document.intentSHA256) { - state = .serviceFailed(.agent) - await systemProxyObserver.suspendSystemProxy() - return - } - let generation = flowGeneration - let outcome = await launchHealthAttempt( - document, - expectedDigest: document.aclSummary != nil - ? runtimeFileStore.receiptIdentity(for: document.intentSHA256) : nil) - // await 恢复后重查意图与代际:agent 已被关闭或已有新 execute 时, - // 失败呈现与系统代理挂起都归属新的意图状态,本任务不得再改写。 - guard !Task.isCancelled, generation == flowGeneration, settings.systemProxyEnabled, - settings.agentEnabled - else { - return - } - switch outcome { - case .ready: - let wasUnavailable = systemProxyObserver.systemProxyWasUnavailable - switch state { - case .running, .firewallBlocked: break - default: await presentFirewallStatus(for: document) - } - if !systemProxyExitAvailable { + if let failure = runtimeFileStore.readFailure(), + let deploymentID = runtimeFileStore.receiptIdentity(for: document.intentSHA256), + failure.deploymentID == deploymentID + { + state = .serviceFailed(.agent) await systemProxyObserver.suspendSystemProxy() - } else if wasUnavailable || systemProxyObserver.systemProxyInitialApplyPending { + } else if systemProxyObserver.systemProxyInitialApplyPending { await systemProxyObserver.convergeSystemProxy() } - case .agentLost: - state = .serviceFailed(.agent) - await systemProxyObserver.suspendSystemProxy() - case .retry(let failure): - if let failure { presentEndpointFailure(failure) } else { state = .serviceFailed(.agent) } - await systemProxyObserver.suspendSystemProxy() } } } diff --git a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController.swift index f6382ddc..0e00ef3d 100644 --- a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController.swift @@ -145,7 +145,7 @@ final class ProxyRuntimeController { ruleApplicationDelay: @escaping () async throws -> Void = { try await Task.sleep(for: .milliseconds(150)) }, - systemProxyHealthPollIntervalNanoseconds: UInt64 = 2_000_000_000, + systemProxyHealthPollIntervalNanoseconds: UInt64 = 5_000_000_000, helperRefreshDelayNanoseconds: UInt64 = 15_000_000_000, sendSignal: @escaping @Sendable (Int32, Int32) -> Int32 = { kill($0, $1) }, processIsAlive: @escaping @Sendable (Int32) -> Bool = { $0 > 0 && kill($0, 0) == 0 } diff --git a/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowIntegrationTests+SystemProxyInspection.swift b/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowIntegrationTests+SystemProxyInspection.swift index 6de26bcd..41d3deec 100644 --- a/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowIntegrationTests+SystemProxyInspection.swift +++ b/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowIntegrationTests+SystemProxyInspection.swift @@ -24,6 +24,13 @@ extension ProxyControlWorkflowIntegrationTests { XCTAssertTrue(predicate(composition.control.snapshot), "实际快照:\(composition.control.snapshot)") } + private func waitForSystemProxyObservationCycle(_ composition: Composition) async throws { + systemProxyHelper.setStatus(.requiresApproval) + try await waitForSnapshot(composition) { $0.systemProxyApprovalRequired } + systemProxyHelper.setStatus(.approved) + try await waitForSnapshot(composition) { !$0.systemProxyApprovalRequired } + } + func testCurrentDifferencesIncludeChangedAndNewServicesAndDisappearWhenRestored() async throws { let composition = try await enabledSystemProxyComposition() let original = systemProxy.services[0] @@ -121,16 +128,61 @@ extension ProxyControlWorkflowIntegrationTests { XCTAssertEqual(systemProxy.applied.count, 1) } - func testHealthLossClearsOnceAndRecoveryAutomaticallyApplies() async throws { + func testSteadyObservationDoesNotProbeOrInferFailureFromReceiptState() async throws { let probe = ProxyRuntimeFixture.FakeProbe.reachable() let composition = try await enabledSystemProxyComposition(probe: probe) - probe.setOutcomes([.refused(detail: "local stopped")]) + let store = composition.controller.runtimeFileStore + let callsAfterLaunch = probe.callCount + probe.setOutcomes([.refused(detail: "must not probe while running")]) + try JSONEncoder().encode( + RuntimeDeploymentFailure(deploymentID: UUID().uuidString, reason: "older deployment failed") + ).write(to: store.failureFileURL) + + for receipt in [ + nil, + RuntimeDeploymentReceipt(processID: 42, deploymentID: UUID().uuidString), + RuntimeDeploymentReceipt( + processID: 9999, + deploymentID: try XCTUnwrap(store.readRuntimeReceipt()).deploymentID), + ] { + if let receipt { + try JSONEncoder().encode(receipt).write(to: store.runtimeStatusFileURL) + } else { + try FileManager.default.removeItem(at: store.runtimeStatusFileURL) + } + // Two approval transitions prove that observation continues across complete iterations. + try await waitForSystemProxyObservationCycle(composition) + XCTAssertEqual(systemProxy.clearCount, 0) + XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .applied) + XCTAssertEqual(composition.control.snapshot.runtime.status, .running) + } + let metadata = try XCTUnwrap(RuntimePublication.metadata(at: store.supervisionFileURL)) + try store.recordFailure(reason: "unattributable after identity loss") + try FileManager.default.removeItem( + at: store.directoryURL.appendingPathComponent(metadata.intentMetadata)) + try await waitForSystemProxyObservationCycle(composition) + XCTAssertEqual(systemProxy.clearCount, 0, "无法匹配部署身份时不推断当前故障") + try FileManager.default.removeItem(at: store.supervisionFileURL) + try await waitForSystemProxyObservationCycle(composition) + XCTAssertEqual(systemProxy.clearCount, 0, "缺失监管资料不能把旧部署失败变成当前故障") + XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .applied) + XCTAssertEqual(probe.callCount, callsAfterLaunch) + } + + func testDeploymentFailureClearsOnceAndExplicitRetryApplies() async throws { + let probe = ProxyRuntimeFixture.FakeProbe.reachable() + let composition = try await enabledSystemProxyComposition(probe: probe) + try composition.controller.runtimeFileStore.recordFailure(reason: "Agent confirmed failure") try await waitForSnapshot(composition) { $0.systemProxyApplication == .paused } XCTAssertTrue(composition.control.snapshot.systemProxyIntentEnabled) XCTAssertEqual(systemProxy.clearCount, 1) await emitAndWaitForInspection(.networkPath, in: composition) XCTAssertEqual(systemProxy.clearCount, 1, "失效期间不循环重试") - probe.setOutcomes([.reachable]) + try composition.controller.runtimeFileStore.clearFailure() + try await waitForSystemProxyObservationCycle(composition) + XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .paused) + XCTAssertEqual(systemProxy.applied.count, 1, "删除失败记录本身不是恢复证明") + _ = await composition.control.setAgentEnabled(true) try await waitForSnapshot(composition) { $0.systemProxyApplication == .applied } XCTAssertEqual(systemProxy.applied.count, 2) } @@ -138,12 +190,12 @@ extension ProxyControlWorkflowIntegrationTests { func testPausedIntentOffPreventsRecoveryApply() async throws { let probe = ProxyRuntimeFixture.FakeProbe.reachable() let composition = try await enabledSystemProxyComposition(probe: probe) - probe.setOutcomes([.refused(detail: "stopped")]) + try composition.controller.runtimeFileStore.recordFailure(reason: "Agent confirmed failure") try await waitForSnapshot(composition) { $0.systemProxyApplication == .paused } let healthObservation = composition.controller.systemProxyObserver.systemProxyHealthTask XCTAssertNotNil(healthObservation) _ = await composition.control.setSystemProxyEnabled(false) - probe.setOutcomes([.reachable]) + try composition.controller.runtimeFileStore.clearFailure() await healthObservation?.value XCTAssertFalse(networkMonitor.isObserving, "关闭意图后应停止观察与自动恢复") XCTAssertEqual(systemProxy.applied.count, 1) @@ -247,11 +299,11 @@ extension ProxyControlWorkflowIntegrationTests { XCTAssertEqual(systemProxy.applied.count, 2, "关闭意图下保存不应用") } - func testHealthClearFailurePreservesIntentAndNeedsExplicitRetry() async throws { + func testDeploymentFailureClearErrorPreservesIntentAndNeedsExplicitRetry() async throws { let probe = ProxyRuntimeFixture.FakeProbe.reachable() let composition = try await enabledSystemProxyComposition(probe: probe) systemProxy.clearError = SystemProxyError.commitFailed("busy") - probe.setOutcomes([.refused(detail: "local stopped")]) + try composition.controller.runtimeFileStore.recordFailure(reason: "Agent confirmed failure") try await waitForSnapshot(composition) { $0.systemProxyApplication.isClearFailure } XCTAssertTrue(composition.control.snapshot.systemProxyIntentEnabled) XCTAssertTrue(composition.control.snapshot.systemProxyInspection.canRetryClear) @@ -316,7 +368,7 @@ extension ProxyControlWorkflowIntegrationTests { systemProxy.beforeRead = { await gate.wait() } let read = Task { await composition.control.recheckSystemProxy() } await fulfillment(of: [gate.started], timeout: 2) - probe.setOutcomes([.refused(detail: "stopped")]) + try composition.controller.runtimeFileStore.recordFailure(reason: "Agent confirmed failure") try await waitForSnapshot(composition) { $0.systemProxyApplication == .paused } gate.resume() _ = await read.value @@ -333,7 +385,7 @@ extension ProxyControlWorkflowIntegrationTests { systemProxy.beforeRead = { await gate.wait() } let read = Task { await composition.control.recheckSystemProxy() } await fulfillment(of: [gate.started], timeout: 2) - probe.setOutcomes([.refused(detail: "stopped")]) + try composition.controller.runtimeFileStore.recordFailure(reason: "Agent confirmed failure") try await waitForSnapshot(composition) { $0.systemProxyApplication.isClearFailure } gate.resume() _ = await read.value @@ -377,7 +429,7 @@ extension ProxyControlWorkflowIntegrationTests { let repair = Task { await composition.control.repairSystemProxy() } await fulfillment(of: [gate.started], timeout: 2) XCTAssertTrue(composition.control.snapshot.systemProxyInspection.isBusy) - probe.setOutcomes([.refused(detail: "stopped")]) + try composition.controller.runtimeFileStore.recordFailure(reason: "Agent confirmed failure") try await waitForSnapshot(composition) { $0.runtime.status != .running } gate.resume() _ = await repair.value diff --git a/docs/adr/0022-observe-system-proxy-differences-and-repair.md b/docs/adr/0022-observe-system-proxy-differences-and-repair.md index 0ad84bc6..a22c38f1 100644 --- a/docs/adr/0022-observe-system-proxy-differences-and-repair.md +++ b/docs/adr/0022-observe-system-proxy-differences-and-repair.md @@ -17,3 +17,5 @@ Observation and health-driven system proxy actions run only while the GUI proces Operation results and actionable blockers are separate presentation facts: helper approval must remain visible alongside apply, repair, or cleanup failure. Approval resumes an initial enable request that has not executed, but does not retry an already failed operation. After approval, show the corresponding repair or cleanup retry action. Read failures offer a read-only check action and cannot establish that settings are applied. This decision supersedes ADR-0019's external-change suppression and passive reapplication and ADR-0020's network/launch application, health-gate preservation, cleanup-only-on-intent-off, and all-service scope for repair. ADR-0020's privileged helper boundary, registration, typed configuration, field preservation, and unconditional cleanup mechanism remain. The accepted UI states, copy, and review scenarios are recorded in [System proxy runtime control](../design/system-proxy-runtime-control.md). + +[ADR-0049](0049-agent-owned-runtime-failure-observation.md) narrows steady-state runtime failure observation to Agent-confirmed deployment failures, with a five-second read-only poll. It removes periodic inbound connections and independent receipt/PID failure inference; explicit deployment readiness and successful convergence remain responsible for recovery. diff --git a/docs/adr/0049-agent-owned-runtime-failure-observation.md b/docs/adr/0049-agent-owned-runtime-failure-observation.md new file mode 100644 index 00000000..37dd9657 --- /dev/null +++ b/docs/adr/0049-agent-owned-runtime-failure-observation.md @@ -0,0 +1,9 @@ +# 稳定运行期间由 Agent 独占故障判定 + +系统代理的稳定期观察不再主动连接 SOCKS 或 HTTP 入站,也不再根据 PID 存活、运行回执缺失或身份不匹配独立判定运行故障。Agent 已负责 sslocal 子进程监管、自动重启与明确部署失败记录;GUI 重复连接入站会持续触发 sslocal 的 SOCKS5 EOF 日志,又可能把正常重启期间的回执空窗当成故障。本决定细化 ADR-0022 的运行故障观察与暂停边界,延续 ADR-0037 的明确失败屏障。 + +GUI 运行期间,系统代理意图开启时,每 5 秒读取与当前部署匹配的明确失败记录。发现明确失败后,呈现运行失败并暂停系统代理,保留开启意图;清理失败仍须显式重试,不因轮询反复清理。没有失败记录只表示尚无明确失败结论,不构成健康证明;不增加连续失败计数、宽限窗口或额外的回执/PID 故障检查。后台自动重启期间保留系统代理设置,不因远端服务器或网络波动暂停系统代理。 + +启动、重启及显式部署的就绪确认保留,仍可能产生少量 EOF 日志。明确失败后的显式重试或新部署,沿用现有成功收敛路径恢复系统代理;失败记录被删除本身不代表恢复,不触发周期性自动重应用。用户主动关闭代理服务时,仍直接清理系统代理并关闭其意图,然后停止后台服务,不等待轮询。观察循环继续刷新 helper 批准状态与操作入口,并继续处理待批准后尚未执行的首次应用。 + +代价是 GUI 不再检测“进程仍存活但监听端口消失”,也不兜底推断 Agent 未能持久记录的故障。例如 Agent 异常退出或监管资料缺失/损坏不保证产生失败记录;Agent 异常退出的恢复交给现有 launchd 策略。5 秒是失败记录产生后的常规检查间隔,不是从子进程退出起算的严格暂停完成时限,GUI 退出后也不执行检查。拒绝协议或远端连通性探测来扩大健康含义,避免把请求路径的暂时波动升级为系统代理设置变更。