diff --git a/ShadowsocksX-NG2/Tests/PluginMappingCommitTests.swift b/ShadowsocksX-NG2/Tests/PluginMappingCommitTests.swift new file mode 100644 index 00000000..446c27cc --- /dev/null +++ b/ShadowsocksX-NG2/Tests/PluginMappingCommitTests.swift @@ -0,0 +1,274 @@ +import Foundation +import Testing +import XCTest + +@testable import ShadowsocksX_NG2 + +@MainActor +struct PluginMappingCommitTests { + @Test func runtimeFailureDoesNotUndoSavedMapping() async throws { + let fixture = try PluginMappingCommitFixture() + defer { fixture.cleanUp() } + try await fixture.controller.activate(fixture.group) + #expect(fixture.controller.state == .running) + try FileManager.default.removeItem(at: fixture.files.pidFileURL) + fixture.agent.registerError = NSError(domain: "PluginMappingCommitTests", code: 1) + try fixture.plugins.commit([.update(program: "tool", path: fixture.second.path)]) + let outcome = await fixture.nextApplication() + guard case .failed = outcome else { + Issue.record("Expected runtime failure after the mapping was saved") + return + } + #expect(fixture.plugins.userMappings["tool"] == fixture.second.path) + let restored = PluginCatalog(store: fixture.store, inspector: CommitPluginInspector()) + #expect(restored.executablePath(forProgram: "tool") == fixture.second.path) + } + + @Test func unrelatedAndIdenticalMappingsDoNotRestartHealthyRuntime() async throws { + let fixture = try PluginMappingCommitFixture() + defer { fixture.cleanUp() } + try await fixture.controller.activate(fixture.group) + #expect(fixture.controller.state == .running) + let registered = fixture.agent.registerCount + let unregistered = fixture.agent.unregisterCount + let signals = fixture.signals.count + let deployed = try #require(fixture.files.readData()) + try fixture.plugins.commit([.add(program: "unused", path: fixture.second.path)]) + _ = await fixture.nextApplication() + #expect(fixture.agent.registerCount == registered) + #expect(fixture.agent.unregisterCount == unregistered) + #expect(fixture.signals.count == signals) + #expect(fixture.files.readData() == deployed) + let generation = fixture.plugins.catalogSnapshot().generation + try fixture.plugins.commit([.update(program: "unused", path: fixture.second.path)]) + await fixture.drainMainQueue() + #expect(fixture.plugins.catalogSnapshot().generation == generation) + #expect(fixture.agent.registerCount == registered) + #expect(fixture.agent.unregisterCount == unregistered) + #expect(fixture.signals.count == signals) + } + + @Test func mappingRemovalRevalidatesGroupBeforeRuntimeApplication() async throws { + let fixture = try PluginMappingCommitFixture() + defer { fixture.cleanUp() } + try await fixture.controller.activate(fixture.group) + #expect(fixture.controller.state == .running) + try fixture.plugins.commit([.remove(program: "tool")]) + let group = try #require(fixture.workflow.tree.roots.first) + #expect(group.invalidDescendantCount == 1) + #expect( + group.find(fixture.pluginServer)?.invalidReasons == [.pluginNotProvided(program: "tool")]) + #expect(group.find(fixture.plainServer)?.isInvalid == false) + _ = await fixture.nextApplication() + #expect(fixture.controller.activeTargetID == fixture.group) + #expect( + fixture.controller.skippedServers == [ + SkippedServer(id: fixture.pluginServer, reason: .pluginNotProvided(program: "tool")) + ]) + let document = try #require(fixture.files.loadDocument()) + #expect(document.servers.count == 1) + #expect(document.servers.first?.password == "pw-Plain") + #expect(fixture.controller.state == .running) + } + + @Test(arguments: [false, true]) + func newerMappingWinsBeforeApplicationAndDuringHealthCheck(inFlight: Bool) async throws { + let fixture = try PluginMappingCommitFixture() + defer { fixture.cleanUp() } + try await fixture.controller.activate(fixture.group) + #expect(fixture.controller.state == .running) + let started = XCTestExpectation(description: "mapping A entered health check") + if inFlight { fixture.probe.arm(started: started) } + try fixture.plugins.commit([.update(program: "tool", path: fixture.second.path)]) + if inFlight { + let result = await XCTWaiter.fulfillment(of: [started], timeout: 3) + #expect(result == .completed) + } + try fixture.plugins.commit([.update(program: "tool", path: fixture.third.path)]) + fixture.probe.release() + _ = await fixture.nextApplication() + if inFlight { _ = await fixture.nextApplication() } + #expect(fixture.plugins.userMappings["tool"] == fixture.third.path) + #expect(try fixture.store.load()["tool"] == fixture.third.path) + #expect(fixture.files.loadDocument()?.servers.first?.plugin == fixture.third.path) + #expect(fixture.controller.state == .running) + if !inFlight { #expect(fixture.applicationPaths == [fixture.third.path]) } + } + + @Test func atomicWriteFailurePreservesCommittedFactsAndDoesNotApply() async throws { + let fixture = try PluginMappingCommitFixture() + defer { fixture.cleanUp() } + try await fixture.controller.activate(fixture.group) + let generation = fixture.plugins.catalogSnapshot().generation + let tree = fixture.workflow.tree + let contract = fixture.files.readData() + let registered = fixture.agent.registerCount + let mappingsDirectory = fixture.store.fileURL.deletingLastPathComponent() + let backup = fixture.directory.appendingPathComponent("saved-mappings") + try FileManager.default.moveItem(at: mappingsDirectory, to: backup) + try Data("blocks temporary file creation".utf8).write(to: mappingsDirectory) + #expect(throws: (any Error).self) { + try fixture.plugins.commit([.update(program: "tool", path: fixture.second.path)]) + } + #expect(fixture.plugins.userMappings["tool"] == fixture.first.path) + #expect(fixture.plugins.catalogSnapshot().generation == generation) + #expect(fixture.workflow.tree == tree) + #expect(fixture.agent.registerCount == registered) + #expect(fixture.files.readData() == contract) + await fixture.drainMainQueue() + #expect(fixture.applicationStarts == 0) + #expect(fixture.applicationPaths.isEmpty) + try FileManager.default.removeItem(at: mappingsDirectory) + try FileManager.default.moveItem(at: backup, to: mappingsDirectory) + #expect(try fixture.store.load()["tool"] == fixture.first.path) + } + +} + +private struct CommitPluginInspector: PluginInspecting { + func inspect(_ executable: URL) async -> PluginSecurityFacts { + PluginSecurityFacts(quarantine: .absent, signature: .notApplicable, policy: .notApplicable) + } +} + +/// Production commit wiring with private files and controllable runtime effects. +@MainActor +private final class PluginMappingCommitFixture { + let directory: URL + let first: URL + let second: URL + let third: URL + let store: PluginMappingFileStore + let plugins: PluginCatalog + let files: RuntimeFileStore + let agent = ProxyRuntimeFixture.FakeLaunchAgent() + let probe = ProxyRuntimeFixture.BlockingProbe() + let signals = CommitSignals() + let controller: ProxyRuntimeController + let workflow: CatalogWorkflow + let group: NodeID + let pluginServer: NodeID + let plainServer: NodeID + private(set) var applicationStarts = 0 + private(set) var applicationPaths: [String] = [] + private var applications: AsyncStream.Iterator + + init() throws { + let runtime = ProxyRuntimeFixture.makeTemporaryRuntime() + directory = runtime.directory + first = directory.appendingPathComponent("first") + second = directory.appendingPathComponent("second") + third = directory.appendingPathComponent("third") + for binary in [first, second, third] { + try Data("#!/bin/sh\n".utf8).write(to: binary) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) + } + store = PluginMappingFileStore( + fileURL: directory.appendingPathComponent("mappings/plugins.json")) + plugins = PluginCatalog(store: store, inspector: CommitPluginInspector()) + try plugins.commit([.add(program: "tool", path: first.path)]) + let credentials = InMemoryCredentialStore() + var catalog = ConfigurationCatalog() + group = try catalog.addGroup("Group") + pluginServer = try ActivationFixture.addPlainServer( + "Plugin", to: group, in: &catalog, credentials: credentials) + plainServer = try ActivationFixture.addPlainServer( + "Plain", to: group, in: &catalog, credentials: credentials) + guard case .server(var fields) = try #require(catalog.entry(for: pluginServer)).kind else { + throw CocoaError(.fileReadCorruptFile) + } + fields.pluginProgram = "tool" + try catalog.updateServer(pluginServer, with: fields) + let catalogStore = CatalogFileStore(fileURL: directory.appendingPathComponent("catalog.json")) + try catalogStore.save(CatalogDocument(catalog: catalog)) + let bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: catalogStore) + files = RuntimeFileStore(fileURL: runtime.contract) + let files = files + agent.onRegister = { + try? Data("42".utf8).write(to: files.pidFileURL) + if let document = files.loadDocument() { + try? files.writeRuntimeReceipt(for: document, processID: 42) + } + } + let contractURL = files.fileURL + let signals = signals + controller = ProxyRuntimeController( + catalogSnapshotReader: bootstrap.catalogSnapshotReader, + activationFileStore: ActivationStateFileStore( + fileURL: directory.appendingPathComponent("activation.json")), + runtimeFileStore: files, credentials: credentials, plugins: plugins, + listenRestore: RestoredListenSettings( + settings: ActivationFixture.listen, unreadableError: nil), + settingsStore: InMemoryProxySettingsStore(), + customRuleStore: CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json")), + appBundle: AppArtifact.bundle, + ruleSnapshots: BuiltinRuleSnapshots(loader: ProxyRuntimeFixture.controlFlowRuleSnapshot), + settingsRestore: RestoredProxySettings( + settings: ProxySettings(listen: ActivationFixture.listen, agentEnabled: true), + unreadableError: nil), + agent: agent, probe: probe, systemProxy: ProxyRuntimeFixture.FakeSystemProxy(), + systemProxyHelper: ProxyRuntimeFixture.FakeSystemProxyHelperService(), + firewallChecker: ProxyRuntimeFixture.FakeFirewallChecker(), + launchHealthTimeoutSeconds: 0.2, + launchHealthRetryDelay: { try await Task.sleep(for: .milliseconds(10)) }, + sendSignal: { _, signal in + guard signal != 0 else { return 0 } + signals.record() + let store = RuntimeFileStore(fileURL: contractURL) + if let document = store.loadDocument() { + try? store.writeRuntimeReceipt(for: document, processID: 42) + } + return 0 + }, processIsAlive: { $0 == 42 }) + workflow = makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator( + fileStore: catalogStore, runtime: ProxyRuntimeSyncAdapter(controller: controller), + bootstrap: bootstrap), + credentials: credentials, plugins: plugins) + let stream = AsyncStream.makeStream() + applications = stream.stream.makeAsyncIterator() + let controller = controller + let workflow = workflow + plugins.connect( + invalidateRuntime: { [weak controller] in controller?.pluginMappingsDidPublish() }, + revalidate: { [weak workflow] in workflow?.republishCommittedState() }, + converge: { [weak self, weak controller] in + guard let controller else { return } + self?.applicationStarts += 1 + let outcome = await controller.catalogDidCommit( + snapshot: controller.catalogSnapshotReader.catalogSnapshot) + if let path = files.loadDocument()?.servers.first?.plugin { + self?.applicationPaths.append(path) + } + stream.continuation.yield(outcome) + }) + } + + func nextApplication() async -> RuntimeSyncOutcome? { + var iterator = applications + let outcome = await iterator.next(isolation: MainActor.shared) + applications = iterator + return outcome + } + + /// Observe jobs already submitted to the main executor before asserting no application. + func drainMainQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } + + func cleanUp() { + probe.release() + try? FileManager.default.removeItem(at: directory) + } +} + +private final class CommitSignals: @unchecked Sendable { + private let lock = NSLock() + private var recorded = 0 + + var count: Int { lock.withLock { recorded } } + + func record() { lock.withLock { recorded += 1 } } +} diff --git a/docs/design/user-plugin-mapping-commit-spec.md b/docs/design/user-plugin-mapping-commit-spec.md new file mode 100644 index 00000000..140748ae --- /dev/null +++ b/docs/design/user-plugin-mapping-commit-spec.md @@ -0,0 +1,20 @@ +# 用户插件映射提交回归(候选 3) + +本文件记录 2026-10-05 用户确认的任务范围,基线为 `e74f8df`,仅作任务历史,不代表后续代码现状。 + +## 已定范围 + +保留 `PluginCatalog` 为映射提交 module,通过其 `commit` interface 验证验证、原子保存、发布、重校验与后续运行收敛。目录工作流和运行控制器作为集成观察面,不新增提交框架或进程启动 adapter。 + +- 保存失败不得改变已提交映射、代次或目录投影,也不得触发运行应用。 +- 运行应用失败不得撤销保存;重建目录后仍恢复用户保存的映射。 +- 连续 A→B 保存以 B 为最终意图;尚未开始的旧任务不得应用 A,已在运行的旧任务不得覆盖 B 的最终运行状态或契约。 +- 映射变化立即重校验配置组成员;无效成员不参与运行,仍有有效成员时保留活动组目标。 +- 无关映射变化与相同映射提交不得重启健康且契约一致的代理。 +- 同一快照指同一已提交映射代次;文件可用性在每次激活或运行派生时重新检查。 + +只修复回归揭示的问题。不增加文件监听、文件替换自动重启、逐次映射保存的应用结果记录或管理 UI。运行失败继续由既有控制器状态呈现。 + +## 验证 + +采用进程私有临时目录、真实映射和运行文件存储、注入的 LaunchAgent 与可控健康探测。测试不会运行用户插件或修改真实系统代理。新增测试默认 Swift Testing;异步交错用已有 gate,不使用任意等待。