mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 05:33:45 +00:00
test: make HTTP and plugin checks deterministic
This commit is contained in:
@@ -73,6 +73,11 @@ static int response_status(const char *bytes, size_t length) {
|
||||
|
||||
ConnectivityHTTPResult ConnectivityHTTPProbe(const char *request, int proxy_port,
|
||||
int timeout_ms, ConnectivityHTTPCancellation *token) {
|
||||
return ConnectivityHTTPProbeWithReader(request, proxy_port, timeout_ms, token, NULL, NULL);
|
||||
}
|
||||
|
||||
ConnectivityHTTPResult ConnectivityHTTPProbeWithReader(const char *request, int proxy_port,
|
||||
int timeout_ms, ConnectivityHTTPCancellation *token, ConnectivityHTTPReader reader, void *context) {
|
||||
ConnectivityHTTPResult result = { .stage = 1, .local_tcp_ms = -1, .send_ms = -1,
|
||||
.first_byte_ms = -1, .headers_ms = -1 };
|
||||
int fd = -1;
|
||||
@@ -109,7 +114,8 @@ ConnectivityHTTPResult ConnectivityHTTPProbe(const char *request, int proxy_port
|
||||
size_t used = 0, consumed = 0, cumulative = 0;
|
||||
while (used < sizeof(bytes)) {
|
||||
if (!ready(fd, POLLIN, deadline, token)) goto cleanup;
|
||||
ssize_t count = recv(fd, bytes + used, sizeof(bytes) - used, 0);
|
||||
ssize_t count = reader ? reader(bytes + used, sizeof(bytes) - used, context)
|
||||
: recv(fd, bytes + used, sizeof(bytes) - used, 0);
|
||||
if (count <= 0) {
|
||||
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)) continue;
|
||||
goto cleanup;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef ConnectivityHTTPTransport_h
|
||||
#define ConnectivityHTTPTransport_h
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
typedef struct ConnectivityHTTPCancellation ConnectivityHTTPCancellation;
|
||||
typedef struct {
|
||||
@@ -20,4 +21,8 @@ void ConnectivityHTTPDestroyCancellation(ConnectivityHTTPCancellation *token);
|
||||
uint64_t ConnectivityHTTPNow(void);
|
||||
ConnectivityHTTPResult ConnectivityHTTPProbe(const char *request, int proxy_port,
|
||||
int timeout_ms, ConnectivityHTTPCancellation *token);
|
||||
/* A reader override makes response fragmentation independent of TCP packet boundaries. */
|
||||
typedef ssize_t (*ConnectivityHTTPReader)(void *buffer, size_t capacity, void *context);
|
||||
ConnectivityHTTPResult ConnectivityHTTPProbeWithReader(const char *request, int proxy_port,
|
||||
int timeout_ms, ConnectivityHTTPCancellation *token, ConnectivityHTTPReader reader, void *context);
|
||||
#endif
|
||||
|
||||
@@ -6,12 +6,14 @@ import Testing
|
||||
|
||||
@MainActor
|
||||
struct ConnectivityHTTPTests {
|
||||
@Test(arguments: [204, 200, 302, 500])
|
||||
func sendsGETThroughProxyAndStopsAtHeaders(status: Int) async throws {
|
||||
let proxy = try HeaderProxy(status: status)
|
||||
@Test(arguments: [(204, false), (200, false), (302, false), (500, false), (200, true)])
|
||||
func sendsGETThroughProxyAndStopsAtHeaders(status: Int, informational: Bool) async throws {
|
||||
let headers =
|
||||
"HTTP/1.1 \(status) Response\r\nLocation: http://another.invalid/redirect\r\nContent-Length: 99999\r\n\r\n"
|
||||
let proxy = try HeaderProxy(
|
||||
response: (informational ? "HTTP/1.1 100 Continue\r\n\r\n" : "") + headers)
|
||||
defer { proxy.stop() }
|
||||
let port = try await proxy.start()
|
||||
let started = ContinuousClock.now
|
||||
// This name cannot resolve; a successful response proves proxy routing.
|
||||
let id = NodeID.fresh()
|
||||
let module = ServerConnectivity(
|
||||
@@ -30,18 +32,52 @@ struct ConnectivityHTTPTests {
|
||||
} else {
|
||||
#expect(value == .http(status))
|
||||
}
|
||||
#expect(ContinuousClock.now - started < .seconds(2))
|
||||
#expect(proxy.requests.count == 1)
|
||||
#expect(proxy.requests.first?.hasPrefix("GET http://target.invalid/test HTTP/") == true)
|
||||
}
|
||||
|
||||
@Test func informationalAndFragmentedHeadersDoNotWaitForBody() async throws {
|
||||
// Leave the socket readable while the reader supplies exact response chunks.
|
||||
let proxy = try HeaderProxy(response: "ready")
|
||||
defer { proxy.stop() }
|
||||
let port = try await proxy.start()
|
||||
let result = await Task.detached {
|
||||
var chunks = [
|
||||
"HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\nContent-Len",
|
||||
"gth: 999999\r\n\r\n",
|
||||
].map { Array($0.utf8) }
|
||||
let cancellation = ConnectivityHTTPCreateCancellation()
|
||||
defer { ConnectivityHTTPDestroyCancellation(cancellation) }
|
||||
return withUnsafeMutablePointer(to: &chunks) { context in
|
||||
"GET http://target.invalid/test HTTP/1.1\r\nHost: target.invalid\r\n\r\n".withCString {
|
||||
ConnectivityHTTPProbeWithReader(
|
||||
$0, Int32(port), 5000, cancellation,
|
||||
{ buffer, capacity, context in
|
||||
let chunks = context!.assumingMemoryBound(to: [[UInt8]].self)
|
||||
guard !chunks.pointee.isEmpty else { return 0 }
|
||||
let chunk = chunks.pointee.removeFirst()
|
||||
precondition(chunk.count <= capacity)
|
||||
chunk.withUnsafeBytes {
|
||||
buffer!.copyMemory(from: $0.baseAddress!, byteCount: $0.count)
|
||||
}
|
||||
return chunk.count
|
||||
}, context)
|
||||
}
|
||||
}
|
||||
}.value
|
||||
#expect(result.received_headers == 1)
|
||||
#expect(result.status == 200)
|
||||
#expect(result.timed_out == 0)
|
||||
}
|
||||
|
||||
@Test func fixedAddressPreservesHostAndReportsClientPhases() async throws {
|
||||
let proxy = try HeaderProxy(status: 204)
|
||||
let proxy = try HeaderProxy(
|
||||
response: "HTTP/1.1 204 No Content\r\nContent-Length: 99999\r\n\r\n")
|
||||
defer { proxy.stop() }
|
||||
let port = try await proxy.start()
|
||||
let result = await ConnectivityHTTPRequest().observe(
|
||||
url: URL(string: "http://target.invalid:8080/test?q=private%20value")!,
|
||||
targetIP: "2001:db8::1", proxyPort: port, timeout: 1)
|
||||
targetIP: "2001:db8::1", proxyPort: port, timeout: 5)
|
||||
guard case .success = result.outcome else {
|
||||
Issue.record("Expected successful fixed-address request")
|
||||
return
|
||||
@@ -56,61 +92,29 @@ struct ConnectivityHTTPTests {
|
||||
#expect(result.totalMilliseconds >= 0)
|
||||
}
|
||||
|
||||
@Test func informationalAndFragmentedHeadersDoNotWaitForBody() async throws {
|
||||
let proxy = try HeaderProxy(
|
||||
status: nil,
|
||||
parts: [
|
||||
"HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\nContent-Len",
|
||||
"gth: 999999\r\n\r\n",
|
||||
])
|
||||
defer { proxy.stop() }
|
||||
let port = try await proxy.start()
|
||||
let result = await ConnectivityHTTPRequest().observe(
|
||||
url: URL(string: "http://target.invalid/test")!, targetIP: "192.0.2.1", proxyPort: port,
|
||||
timeout: 1)
|
||||
guard case .success = result.outcome else {
|
||||
Issue.record("Expected complete final headers")
|
||||
return
|
||||
}
|
||||
#expect(result.firstByteMilliseconds != nil)
|
||||
#expect(result.headersMilliseconds != nil)
|
||||
#expect(result.totalMilliseconds < 1000)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
"HTTP/2 200 OK\r\n\r\n",
|
||||
"HTTP/1.1 200 OK\r\nInvalid Header\r\n\r\n",
|
||||
"HTTP/1.1 200 OK\r\nX-Large: " + String(repeating: "x", count: 65536),
|
||||
String(repeating: "HTTP/1.1 100 Continue\r\n\r\n", count: 3000),
|
||||
// Each block fits; only the cumulative budget including final headers is exceeded.
|
||||
"HTTP/1.1 100 Continue\r\nX-Large: " + String(repeating: "x", count: 60000) + "\r\n\r\n"
|
||||
+ "HTTP/1.1 204 No Content\r\nX-Large: " + String(repeating: "x", count: 6000) + "\r\n\r\n",
|
||||
])
|
||||
func rejectsMalformedOrUnboundedHeaders(response: String) async throws {
|
||||
let proxy = try HeaderProxy(status: nil, parts: [response])
|
||||
let proxy = try HeaderProxy(response: response)
|
||||
defer { proxy.stop() }
|
||||
let port = try await proxy.start()
|
||||
let result = await ConnectivityHTTPRequest().observe(
|
||||
url: URL(string: "http://target.invalid/test")!, targetIP: "192.0.2.1", proxyPort: port,
|
||||
timeout: 1)
|
||||
timeout: 5)
|
||||
#expect(result.outcome == .network)
|
||||
#expect(result.failureStage == "headers")
|
||||
}
|
||||
|
||||
@Test func cumulativeHeadersIncludeFinalBlockAfterInformationalResponses() async throws {
|
||||
let proxy = try HeaderProxy(
|
||||
status: nil,
|
||||
parts: [
|
||||
String(repeating: "HTTP/1.1 100 Continue\r\n\r\n", count: 2500),
|
||||
"HTTP/1.1 204 No Content\r\nX-Large: " + String(repeating: "x", count: 5000) + "\r\n\r\n",
|
||||
])
|
||||
defer { proxy.stop() }
|
||||
let port = try await proxy.start()
|
||||
let result = await ConnectivityHTTPRequest().observe(
|
||||
url: URL(string: "http://target.invalid/test")!, targetIP: "192.0.2.1", proxyPort: port,
|
||||
timeout: 1)
|
||||
#expect(result.outcome == .network)
|
||||
}
|
||||
|
||||
@Test func httpsIsRejectedBeforeOpeningProxyConnection() async throws {
|
||||
let proxy = try HeaderProxy(status: 204)
|
||||
let proxy = try HeaderProxy(
|
||||
response: "HTTP/1.1 204 No Content\r\nContent-Length: 99999\r\n\r\n")
|
||||
defer { proxy.stop() }
|
||||
let port = try await proxy.start()
|
||||
let result = await ConnectivityHTTPRequest().perform(
|
||||
@@ -121,7 +125,7 @@ struct ConnectivityHTTPTests {
|
||||
|
||||
@Test(.timeLimit(.minutes(1)))
|
||||
func timesOutWhileWaitingForFirstByte() async throws {
|
||||
let proxy = try HeaderProxy(status: nil)
|
||||
let proxy = try HeaderProxy()
|
||||
defer { proxy.stop() }
|
||||
let port = try await proxy.start()
|
||||
let result = await ConnectivityHTTPRequest().observe(
|
||||
@@ -134,7 +138,7 @@ struct ConnectivityHTTPTests {
|
||||
}
|
||||
|
||||
@Test func cancellationEndsRequestWithoutWaitingForHeaders() async throws {
|
||||
let proxy = try HeaderProxy(status: nil)
|
||||
let proxy = try HeaderProxy()
|
||||
defer { proxy.stop() }
|
||||
let port = try await proxy.start()
|
||||
let id = NodeID.fresh()
|
||||
@@ -167,16 +171,14 @@ struct ConnectivityHTTPTests {
|
||||
/// Controlled HTTP proxy: headers arrive, but the declared body never does.
|
||||
private final class HeaderProxy: @unchecked Sendable {
|
||||
private let listener: NWListener
|
||||
private let status: Int?
|
||||
private let parts: [String]?
|
||||
private let response: String?
|
||||
private let lock = NSLock()
|
||||
private var received: [String] = []
|
||||
private var connections: [NWConnection] = []
|
||||
var requests: [String] { lock.withLock { received } }
|
||||
|
||||
init(status: Int?, parts: [String]? = nil) throws {
|
||||
self.status = status
|
||||
self.parts = parts
|
||||
init(response: String? = nil) throws {
|
||||
self.response = response
|
||||
listener = try NWListener(using: .tcp, on: .any)
|
||||
}
|
||||
|
||||
@@ -186,14 +188,9 @@ private final class HeaderProxy: @unchecked Sendable {
|
||||
connection.start(queue: DispatchQueue(label: UUID().uuidString))
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [self] data, _, _, _ in
|
||||
if let data { lock.withLock { received.append(String(data: data, encoding: .utf8) ?? "") } }
|
||||
if let parts {
|
||||
send(parts, through: connection)
|
||||
return
|
||||
if let response {
|
||||
connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
|
||||
}
|
||||
guard let status else { return }
|
||||
let headers =
|
||||
"HTTP/1.1 \(status) Response\r\nLocation: http://another.invalid/redirect\r\nContent-Length: 99999\r\n\r\n"
|
||||
connection.send(content: Data(headers.utf8), completion: .contentProcessed { _ in })
|
||||
}
|
||||
}
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
@@ -212,19 +209,6 @@ private final class HeaderProxy: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ parts: [String], through connection: NWConnection) {
|
||||
guard let first = parts.first else { return }
|
||||
connection.send(
|
||||
content: Data(first.utf8),
|
||||
completion: .contentProcessed { [self] _ in
|
||||
if parts.count > 1 {
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 0.01) { [self] in
|
||||
send(Array(parts.dropFirst()), through: connection)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func stop() {
|
||||
listener.cancel()
|
||||
listener.newConnectionHandler = nil
|
||||
|
||||
@@ -2,26 +2,16 @@ import Foundation
|
||||
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
@MainActor
|
||||
struct HealthCheckSupersessionControllerContext {
|
||||
let directory: URL
|
||||
let catalogFile: URL
|
||||
let files: RuntimeFileStore
|
||||
let plugins: PluginCatalog
|
||||
let agent: ProxyRuntimeFixture.FakeLaunchAgent
|
||||
let probe: ProxyRuntimeFixture.BlockingProbe
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func makeHealthCheckSupersessionController(
|
||||
_ context: HealthCheckSupersessionControllerContext
|
||||
runtime: ProxyRuntimeFixture.TemporaryRuntime, catalogFile: URL, plugins: PluginCatalog,
|
||||
agent: ProxyRuntimeFixture.FakeLaunchAgent, probe: ProxyRuntimeFixture.BlockingProbe
|
||||
) -> ProxyRuntimeController {
|
||||
let directory = context.directory
|
||||
let catalogFile = context.catalogFile
|
||||
let files = context.files
|
||||
let plugins = context.plugins
|
||||
let agent = context.agent
|
||||
let probe = context.probe
|
||||
let directory = runtime.directory
|
||||
let files = RuntimeFileStore(fileURL: runtime.contract)
|
||||
agent.onRegister = {
|
||||
try? files.writePublishedRuntimeReceipt(processID: 42)
|
||||
}
|
||||
return ProxyRuntimeController(
|
||||
controlPreferences: makeTestProxyControlPreferences(),
|
||||
catalogSnapshotReader: ProxyRuntimeFixture.catalogSnapshotReader(at: catalogFile),
|
||||
|
||||
@@ -208,38 +208,38 @@ struct PluginCatalogTests {
|
||||
inspector: QuietPluginInspector())
|
||||
try plugins.addPlugin(program: "tool", path: first.path)
|
||||
var catalog = ConfigurationCatalog()
|
||||
let id = try ActivationFixture.addPlainServer("Plugin", in: &catalog)
|
||||
var fields = try #require(catalog.entry(for: id)).serverFieldsForPluginTest
|
||||
var fields = ActivationFixture.plainFields(remark: "Plugin")
|
||||
fields.pluginProgram = "tool"
|
||||
try catalog.updateServer(id, with: fields)
|
||||
let id = try catalog.addServer(fields, to: nil)
|
||||
let catalogFile = runtime.directory.appendingPathComponent("catalog.json")
|
||||
try makeTestCatalogStore(fileURL: catalogFile).save(CatalogDocument(catalog: catalog))
|
||||
let agent = ProxyRuntimeFixture.FakeLaunchAgent()
|
||||
let files = RuntimeFileStore(fileURL: runtime.contract)
|
||||
agent.onRegister = {
|
||||
try? files.writePublishedRuntimeReceipt(processID: 42)
|
||||
}
|
||||
let probe = ProxyRuntimeFixture.BlockingProbe()
|
||||
let controller = makeHealthCheckSupersessionController(
|
||||
HealthCheckSupersessionControllerContext(
|
||||
directory: runtime.directory, catalogFile: catalogFile, files: files,
|
||||
plugins: plugins, agent: agent, probe: probe))
|
||||
runtime: runtime, catalogFile: catalogFile, plugins: plugins, agent: agent, probe: probe)
|
||||
let started = XCTestExpectation(description: "old runtime entered health check")
|
||||
probe.arm(started: started)
|
||||
let oldActivation = Task { try await controller.activate(id) }
|
||||
let wait = await XCTWaiter.fulfillment(of: [started], timeout: 3)
|
||||
#expect(wait == .completed)
|
||||
let applied = AsyncStream<Void>.makeStream()
|
||||
plugins.connect(
|
||||
invalidateRuntime: { controller.pluginMappingsDidPublish() }, revalidate: {},
|
||||
converge: {
|
||||
_ = await controller.catalogDidCommit(snapshot: catalog)
|
||||
applied.continuation.yield(())
|
||||
})
|
||||
try plugins.updatePlugin(program: "tool", path: second.path)
|
||||
probe.release()
|
||||
_ = try await oldActivation.value
|
||||
for await _ in applied.stream { break }
|
||||
invalidateRuntime: { controller.pluginMappingsDidPublish() }, revalidate: {}, converge: {})
|
||||
let oldActivation = Task { try await controller.activate(id) }
|
||||
do {
|
||||
try await requireTestPhase([started])
|
||||
try plugins.updatePlugin(program: "tool", path: second.path)
|
||||
probe.release()
|
||||
_ = try await oldActivation.value
|
||||
} catch {
|
||||
probe.release()
|
||||
oldActivation.cancel()
|
||||
_ = await oldActivation.result
|
||||
throw error
|
||||
}
|
||||
// A later successful deployment must not hide stale health-check side effects.
|
||||
#expect(controller.state == .starting)
|
||||
#expect(files.readFailure() == nil)
|
||||
#expect(agent.unregisterCount == 0)
|
||||
let outcome = await controller.catalogDidCommit(snapshot: catalog)
|
||||
#expect(outcome == .converged(skippedServers: []))
|
||||
#expect(files.loadDocument()?.servers.first?.plugin == second.path)
|
||||
#expect(controller.state == .running)
|
||||
}
|
||||
@@ -327,13 +327,6 @@ private struct FailingMappingStore: PluginMappingStoring {
|
||||
func save(_ configuration: PluginConfiguration) throws { throw PluginMappingError.unreadable }
|
||||
}
|
||||
|
||||
extension CatalogEntry {
|
||||
fileprivate var serverFieldsForPluginTest: ServerFields {
|
||||
guard case .server(let fields) = kind else { preconditionFailure("expected server") }
|
||||
return fields
|
||||
}
|
||||
}
|
||||
|
||||
private actor HeldPluginInspector: PluginInspecting {
|
||||
nonisolated let started: AsyncStream<Void>
|
||||
private let signal: AsyncStream<Void>.Continuation
|
||||
|
||||
Reference in New Issue
Block a user