test: 修复 wrapper 生命周期等待与清理并拆类

This commit is contained in:
Qiu Yuzhou
2026-10-04 00:56:51 +08:00
parent 72ee44411e
commit 4f859886e2
3 changed files with 82 additions and 22 deletions
@@ -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 {
@@ -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 {
@@ -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(