test(plugins): cover mapping commit and runtime convergence

This commit is contained in:
Qiu Yuzhou
2026-10-05 11:13:42 +08:00
parent e74f8dfc01
commit 3d8d56b8e1
2 changed files with 294 additions and 0 deletions
@@ -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<RuntimeSyncOutcome>.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<RuntimeSyncOutcome>.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 } }
}
@@ -0,0 +1,20 @@
# 用户插件映射提交回归(候选 3)
本文件记录 2026-10-05 用户确认的任务范围,基线为 `e74f8df`,仅作任务历史,不代表后续代码现状。
## 已定范围
保留 `PluginCatalog` 为映射提交 module,通过其 `commit` interface 验证验证、原子保存、发布、重校验与后续运行收敛。目录工作流和运行控制器作为集成观察面,不新增提交框架或进程启动 adapter。
- 保存失败不得改变已提交映射、代次或目录投影,也不得触发运行应用。
- 运行应用失败不得撤销保存;重建目录后仍恢复用户保存的映射。
- 连续 A→B 保存以 B 为最终意图;尚未开始的旧任务不得应用 A,已在运行的旧任务不得覆盖 B 的最终运行状态或契约。
- 映射变化立即重校验配置组成员;无效成员不参与运行,仍有有效成员时保留活动组目标。
- 无关映射变化与相同映射提交不得重启健康且契约一致的代理。
- 同一快照指同一已提交映射代次;文件可用性在每次激活或运行派生时重新检查。
只修复回归揭示的问题。不增加文件监听、文件替换自动重启、逐次映射保存的应用结果记录或管理 UI。运行失败继续由既有控制器状态呈现。
## 验证
采用进程私有临时目录、真实映射和运行文件存储、注入的 LaunchAgent 与可控健康探测。测试不会运行用户插件或修改真实系统代理。新增测试默认 Swift Testing;异步交错用已有 gate,不使用任意等待。