1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104 | import Foundation
import Testing
@testable import IrdP2PSignaling
/// Runs the node's real Go `signal` and `mailbox` servers on loopback for
/// integration tests. Gated on `AP2P_REPO` (a checkout of the node repo) and a
/// `go` toolchain; the tests skip cleanly otherwise.
final class LocalServers: @unchecked Sendable {
static let appID = "11111111-2222-4333-8444-555555555555"
static let origin = "https://test.example"
static let secret = "0123456789abcdef"
let signalPort: Int
let mailboxPort: Int
private var signal: Process?
private var mailbox: Process?
private let dir: URL
static var repo: String? {
let env = ProcessInfo.processInfo.environment
guard let r = env["AP2P_REPO"], !r.isEmpty, FileManager.default.fileExists(atPath: r + "/signal/main.go") else { return nil }
return r
}
static var isAvailable: Bool { repo != nil && goPath != nil }
static var goPath: String? {
for p in ["/usr/local/go/bin/go", "/opt/homebrew/bin/go", "/usr/bin/go"] where FileManager.default.isExecutableFile(atPath: p) { return p }
return nil
}
var signalBase: URL { URL(string: "http://127.0.0.1:\(signalPort)")! }
var mailboxBase: URL { URL(string: "http://127.0.0.1:\(mailboxPort)/mailbox/v1")! }
var wsURL: URL { URL(string: "ws://127.0.0.1:\(signalPort)/ws")! }
init() throws {
let repo = try #require(LocalServers.repo)
let go = try #require(LocalServers.goPath)
signalPort = Int.random(in: 20000..<40000)
mailboxPort = signalPort + 1
dir = FileManager.default.temporaryDirectory.appendingPathComponent("ird-swift-servers-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir.appendingPathComponent("data"), withIntermediateDirectories: true)
let signalApps = """
{"turn":{"secret":"\(LocalServers.secret)","realm":"ap2p.test","ttl":3600,"urls":["stun:127.0.0.1:3478","turn:127.0.0.1:3478?transport=udp"]},
"limits":{"messages_per_sec":30,"message_burst":60,"max_frame_bytes":65536},
"apps":{"\(LocalServers.appID)":{"hosts":["127.0.0.1","localhost"],"origins":["\(LocalServers.origin)"],
"limits":{"max_rooms":10,"max_room_peers":4,"max_conns_per_ip":50,"max_bytes_per_sec":262144},
"mailbox":{"ttl_hours":1,"max_mb":16,"max_message_kb":64,"mint_ip_per_min":600,"mint_app_per_min":600,"open_mint":true}}}}
"""
let mailboxApps = """
{"node":{"secret":"\(LocalServers.secret)","max_disk_bytes":67108864},
"apps":{"\(LocalServers.appID)":{"hosts":["127.0.0.1","localhost"],"origins":["\(LocalServers.origin)"],"open_mint":true,
"limits":{"mailbox_ttl_hours":1,"mailbox_max_mb":16,"mailbox_max_message_kb":64}}}}
"""
let signalConf = dir.appendingPathComponent("signal-apps.json")
let mailboxConf = dir.appendingPathComponent("mailbox-apps.json")
try signalApps.write(to: signalConf, atomically: true, encoding: .utf8)
try mailboxApps.write(to: mailboxConf, atomically: true, encoding: .utf8)
// each server is its own Go module: run from inside its directory
signal = try LocalServers.launch(go: go, cwd: repo + "/signal", args: ["run", ".", "-listen", "127.0.0.1:\(signalPort)", "-apps", signalConf.path])
mailbox = try LocalServers.launch(go: go, cwd: repo + "/mailbox", args: ["run", ".", "-listen", "127.0.0.1:\(mailboxPort)", "-config", mailboxConf.path, "-data", dir.appendingPathComponent("data").path])
}
private static func launch(go: String, cwd: String, args: [String]) throws -> Process {
let p = Process()
p.executableURL = URL(fileURLWithPath: go)
p.arguments = args
p.currentDirectoryURL = URL(fileURLWithPath: cwd)
p.standardOutput = FileHandle.nullDevice
p.standardError = FileHandle.nullDevice
try p.run()
return p
}
/// Waits until both servers answer (`go run` compiles first, which can take a while).
func waitUntilReady(timeout: Duration = .seconds(120)) async throws {
let deadline = ContinuousClock.now + timeout
let http = URLSessionHTTPTransport()
var signalUp = false, mailboxUp = false
while ContinuousClock.now < deadline, !(signalUp && mailboxUp) {
if signal?.isRunning == false || mailbox?.isRunning == false { throw P2PError.config("a local server exited early") }
if !signalUp, let r = try? await http.send(URLRequest(url: signalBase.appendingPathComponent("healthz"), timeoutInterval: 1)), r.status == 200 { signalUp = true }
if !mailboxUp, let r = try? await http.send(URLRequest(url: mailboxBase.appendingPathComponent("rooms/probe/messages"), timeoutInterval: 1)), r.status == 401 { mailboxUp = true }
if !(signalUp && mailboxUp) { try await Task.sleep(for: .milliseconds(200)) }
}
guard signalUp && mailboxUp else { throw P2PError.timeout }
}
func stop() {
for p in [signal, mailbox] { p?.terminate() }
// `go run` execs the compiled binary as a child; make sure the listeners go away too
for port in [signalPort, mailboxPort] {
let k = Process()
k.executableURL = URL(fileURLWithPath: "/bin/sh")
// only the LISTENING process: our own test runner holds client sockets on this port
k.arguments = ["-c", "lsof -ti tcp:\(port) -sTCP:LISTEN | xargs kill 2>/dev/null"]
try? k.run()
k.waitUntilExit()
}
try? FileManager.default.removeItem(at: dir)
}
}
|