mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 21:53:43 +00:00
test: 修复 wrapper 生命周期等待与清理并拆类
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user