From 4f859886e2a3f413aee3175bbeee29269d0e60b6 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sun, 4 Oct 2026 00:56:51 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8D=20wrapper=20?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E7=AD=89=E5=BE=85=E4=B8=8E?= =?UTF-8?q?=E6=B8=85=E7=90=86=E5=B9=B6=E6=8B=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AgentLifecycleTests+ChangeProtocol.swift | 25 ++++--- .../AgentLifecycleTests+CrashRecovery.swift | 6 +- .../Tests/AgentLifecycleTests.swift | 73 ++++++++++++++++--- 3 files changed, 82 insertions(+), 22 deletions(-) diff --git a/ShadowsocksX-NG2/Tests/AgentLifecycleTests+ChangeProtocol.swift b/ShadowsocksX-NG2/Tests/AgentLifecycleTests+ChangeProtocol.swift index 21435e8c..63991f75 100644 --- a/ShadowsocksX-NG2/Tests/AgentLifecycleTests+ChangeProtocol.swift +++ b/ShadowsocksX-NG2/Tests/AgentLifecycleTests+ChangeProtocol.swift @@ -3,7 +3,7 @@ import XCTest @testable import ShadowsocksX_NG2 -extension AgentLifecycleTests { +final class AgentLifecycleChangeProtocolTests: AgentLifecycleTests { // MARK: 变更协议(D5) func testSIGUSR1WithServerOnlyChangeIsForwardedToSslocal() throws { @@ -72,6 +72,15 @@ extension AgentLifecycleTests { }, "ACL 摘要变化应完整重启 sslocal;agent: \(agentLog())" ) + // 再发送 server-only reload,让就绪检查完成后给出明确的拒绝信号。 + let serverReload = ProxyRuntimeFixture.makeDocument(serverAddress: "198.51.100.9") + .replacingACL(.direct(at: runtimeStore.aclFileURL)) + try writeContract(serverReload) + XCTAssertEqual(kill(wrapper.processIdentifier, SIGUSR1), 0) + XCTAssertTrue( + try waitUntil { self.agentLog().contains("reload deferred until listeners are ready") }, + "未绑定监听的重启实例应拒绝发布回执并推迟重载") + XCTAssertFalse(stateLog().contains("SIGUSR1"), "未就绪时不能转发 server-only reload") XCTAssertNil( runtimeStore.readRuntimeReceipt(), "stub 未绑定代理端口时,不得为直连实例发布回执(旧 pid=\(previousProcessID))") @@ -79,7 +88,9 @@ extension AgentLifecycleTests { kill(wrapper.processIdentifier, SIGTERM) XCTAssertEqual(try waitForExit(wrapper), 0) } +} +final class AgentLifecycleStaleListenerTests: AgentLifecycleTests { func testACLReceiptIsNotPublishedWhenOnlyStaleListenersAreReachable() throws { let staleSocksListener = try TCPListenerFixture() let staleHTTPListener = try TCPListenerFixture() @@ -99,12 +110,6 @@ extension AgentLifecycleTests { } let wrapper = try launchWrapper(behavior: "run") - defer { - if wrapper.isRunning { - kill(wrapper.processIdentifier, SIGTERM) - _ = try? waitForExit(wrapper) - } - } XCTAssertTrue( try waitUntil(timeout: 8) { @@ -127,7 +132,9 @@ extension AgentLifecycleTests { runtimeStore.readRuntimeReceipt(), "候选没有绑定端口时,旧监听和 server-only reload 都不能发布直连回执") } +} +final class AgentLifecycleListenDeadlineTests: AgentLifecycleTests { // MARK: 监听建立判定(issue #38,D10) func testListenNotEstablishedWithinDeadlineIsLoggedAndSupervisionContinues() throws { @@ -149,10 +156,6 @@ extension AgentLifecycleTests { kill(wrapper.processIdentifier, SIGTERM) XCTAssertEqual(try waitForExit(wrapper), 0, "后续显式停止链不受影响") } - - private func agentLog() -> String { - (try? String(contentsOf: workDir.appendingPathComponent("agent.log"), encoding: .utf8)) ?? "" - } } private final class TCPListenerFixture { diff --git a/ShadowsocksX-NG2/Tests/AgentLifecycleTests+CrashRecovery.swift b/ShadowsocksX-NG2/Tests/AgentLifecycleTests+CrashRecovery.swift index 39fdac4b..c324efb0 100644 --- a/ShadowsocksX-NG2/Tests/AgentLifecycleTests+CrashRecovery.swift +++ b/ShadowsocksX-NG2/Tests/AgentLifecycleTests+CrashRecovery.swift @@ -3,7 +3,7 @@ import XCTest @testable import ShadowsocksX_NG2 -extension AgentLifecycleTests { +extension AgentLifecycleChangeProtocolTests { // MARK: 崩溃恢复(D2) func testSslocalCrashExitsNonZeroForKeepAliveReplay() throws { @@ -19,8 +19,10 @@ extension AgentLifecycleTests { XCTAssertTrue( try waitUntil { self.stateLog().components(separatedBy: "invoked:").count >= 3 }, "重放应再次拉起 sslocal") - kill(replay.processIdentifier, SIGTERM) + try cleanupWrappers() + XCTAssertFalse(replay.isRunning, "统一清理应停止重放实例") XCTAssertEqual(try waitForExit(replay), 0) + XCTAssertTrue(stateLog().contains("SIGTERM"), "统一清理应先走优雅停止链") } func testUnexpectedCleanChildExitAlsoCountsAsLoss() throws { diff --git a/ShadowsocksX-NG2/Tests/AgentLifecycleTests.swift b/ShadowsocksX-NG2/Tests/AgentLifecycleTests.swift index b2902245..db3dceeb 100644 --- a/ShadowsocksX-NG2/Tests/AgentLifecycleTests.swift +++ b/ShadowsocksX-NG2/Tests/AgentLifecycleTests.swift @@ -6,11 +6,12 @@ import XCTest /// 薄 I/O 缝 ②(spec #21 Testing Decisions):以 stub sslocal 二进制驱动真实 /// wrapper 可执行文件,验证启停协议次序、SIGTERM 链、崩溃恢复、文件清理与 /// SIGUSR1 变更协议(D2/D5)。stub 经环境变量编排行为,全部等待有界。 -final class AgentLifecycleTests: XCTestCase { +class AgentLifecycleTests: XCTestCase { var workDir: URL! var contractURL: URL! private var stubStateURL: URL! private var stubURL: URL! + private var wrappers: [Process] = [] override func setUpWithError() throws { try super.setUpWithError() @@ -20,12 +21,17 @@ final class AgentLifecycleTests: XCTestCase { contractURL = workDir.appendingPathComponent("sslocal-active.json") stubStateURL = workDir.appendingPathComponent("stub-state.log") stubURL = workDir.appendingPathComponent("stub-sslocal.sh") + try FileManager.default.createDirectory( + at: childPIDDirectory, withIntermediateDirectories: true) try writeStubScript() } override func tearDownWithError() throws { - try? FileManager.default.removeItem(at: workDir) - try super.tearDownWithError() + defer { + try? FileManager.default.removeItem(at: workDir) + try? super.tearDownWithError() + } + try cleanupWrappers() } // MARK: 夹具 @@ -43,10 +49,13 @@ final class AgentLifecycleTests: XCTestCase { // stub 记录调用参数与收到的信号;行为由 SSLOCAL_STUB_BEHAVIOR 编排。 let script = """ #!/bin/sh - echo "invoked: $*" >> "$SSLOCAL_STUB_STATE" - echo "dns-force-builtin: ${SS_SYSTEM_DNS_RESOLVER_FORCE_BUILTIN:-unset}" >> "$SSLOCAL_STUB_STATE" + child_pid_file="$SSLOCAL_STUB_PID_DIR/$$" + trap 'rm -f "$child_pid_file"' EXIT trap 'echo "SIGTERM" >> "$SSLOCAL_STUB_STATE"; exit 0' TERM trap 'echo "SIGUSR1" >> "$SSLOCAL_STUB_STATE"' USR1 + echo "$$" > "$child_pid_file" + echo "dns-force-builtin: ${SS_SYSTEM_DNS_RESOLVER_FORCE_BUILTIN:-unset}" >> "$SSLOCAL_STUB_STATE" + echo "invoked: $*" >> "$SSLOCAL_STUB_STATE" case "$SSLOCAL_STUB_BEHAVIOR" in crash) exit 7 ;; exit0) exit 0 ;; @@ -67,9 +76,11 @@ final class AgentLifecycleTests: XCTestCase { environment["SSXNG_SSLOCAL_PATH"] = stubURL.path environment["SSXNG_RUNTIME_DIR"] = workDir.path environment["SSLOCAL_STUB_STATE"] = stubStateURL.path + environment["SSLOCAL_STUB_PID_DIR"] = childPIDDirectory.path environment["SSLOCAL_STUB_BEHAVIOR"] = behavior process.environment = environment try process.run() + wrappers.append(process) return process } @@ -89,8 +100,8 @@ final class AgentLifecycleTests: XCTestCase { func waitUntil( timeout: TimeInterval = 10, _ condition: () throws -> Bool ) throws -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + let deadline = DispatchTime.now() + timeout + while DispatchTime.now() < deadline { if try condition() { return true } Thread.sleep(forTimeInterval: 0.05) } @@ -99,11 +110,51 @@ final class AgentLifecycleTests: XCTestCase { /// 断言 wrapper 在超时内退出并返回退出码。 func waitForExit(_ process: Process, timeout: TimeInterval = 10) throws -> Int32 { - try waitUntil(timeout: timeout) { !process.isRunning } - process.waitUntilExit() + guard try waitUntil(timeout: timeout, { !process.isRunning }) else { + throw LifecycleWaitError.exitTimedOut(process.processIdentifier) + } return process.terminationStatus } + private var childPIDDirectory: URL { + workDir.appendingPathComponent("child-pids", isDirectory: true) + } + + /// 即使用例中途抛错,也先停止所有 wrapper,再清理仍存活的 stub。 + func cleanupWrappers() throws { + let running = wrappers.filter(\.isRunning) + for process in running { kill(process.processIdentifier, SIGTERM) } + _ = try waitUntil(timeout: 12) { running.allSatisfy { !$0.isRunning } } + + let childFiles = try FileManager.default.contentsOfDirectory( + at: childPIDDirectory, includingPropertiesForKeys: nil) + let childPIDs = childFiles.compactMap { Int32($0.lastPathComponent) }.filter { $0 > 0 } + for pid in childPIDs { kill(pid, SIGKILL) } + for process in running where process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + guard + try waitUntil( + timeout: 2, + { + running.allSatisfy { !$0.isRunning } && childPIDs.allSatisfy { !self.pidAlive($0) } + }) + else { + throw LifecycleWaitError.cleanupTimedOut + } + } + + func agentLog() -> String { + (try? String(contentsOf: workDir.appendingPathComponent("agent.log"), encoding: .utf8)) ?? "" + } +} + +private enum LifecycleWaitError: Error { + case exitTimedOut(Int32) + case cleanupTimedOut +} + +final class AgentLifecycleCoreTests: AgentLifecycleTests { // MARK: 启动协议与 SIGTERM 链(D2) func testStartProtocolSpawnsSslocalWithAbsoluteConfigPathThenStopsOnSIGTERM() throws { @@ -115,6 +166,9 @@ final class AgentLifecycleTests: XCTestCase { "wrapper 应以契约绝对路径调用 sslocal,实际:\(stateLog())") XCTAssertTrue(pidAlive(wrapper.processIdentifier), "sslocal 运行期间 wrapper 应保持常驻") + XCTAssertThrowsError(try waitForExit(wrapper, timeout: 0.01)) + XCTAssertTrue(wrapper.isRunning, "等待超时应返回错误,不影响被测进程") + kill(wrapper.processIdentifier, SIGTERM) let exitStatus = try waitForExit(wrapper) @@ -213,6 +267,7 @@ final class AgentLifecycleTests: XCTestCase { ) ?? "") XCTAssertEqual(recordedPID, wrapper.processIdentifier, "pid 文件记录 wrapper 自身 pid") + XCTAssertTrue(try waitUntil { self.stateLog().contains("invoked:") }) kill(wrapper.processIdentifier, SIGTERM) _ = try waitForExit(wrapper) XCTAssertFalse(